From 1fc355709c025fd256c5a4035356e15a5a05b23d Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:23:16 +0200 Subject: [PATCH] feat: Db-backed user drafts (#9351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Db draft removal * refactor: drop unsaved-changes confirmation modal from editors * fix: remove nodraft from flow row edit link * fix: remove nodraft from app and raw app edit buttons * fix: remove nodraft from all edit links * fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps * feat: add username column to draft table for user-scoped drafts * feat: add sync_drafts and list_users_with_draft_on_path endpoints * feat: add UserDraftDbSyncer service for bi-directional draft sync * feat: wire UserDraft.save through DbSyncer + conflict modal * refactor: gate useLocalStorageValue nested-update effect behind opt-in flag * refactor: move sync force flag from request-level to per-entry * feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths * refactor: route draft permission check through authed.folders + RLS, drop client-supplied email * feat: support draft deletion via sync (value: null) with same conflict semantics * feat: surface other users' drafts in editors with diff+fork action * refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum * perf: add (workspace_id, email, created_at) partial index for sync hot path * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6 New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. * fix(userdraft): trigger sync on deep mutations via readFieldsRecursively * Rollback UserDraft * remove queuing logic * pushDrafts * refactor: remove draft sync layer and conflict modal * feat: add save_draft, list_drafts, get_draft routes * feat: add get_draft overlay to getScriptByPath * feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers * feat: support null value in save_draft for deletes * readLastSyncMap * feat: redirect /add pages to /edit/draft_uuid with new_draft flag * fix: inline get_draft query field instead of flattening * fix: drop dangling nobackenddraft assignment in flows edit * feat: include user drafts in list endpoints with is_draft flag * fix: prefix draft paths with u/{user} and seed editor state on new_draft * fix: route draft-only deletes through UserDraftDbSyncer on home page * feat: delete user drafts when their underlying item is deleted * fix: empty path seed on new_draft so friendly auto-name fires * feat: re-add Draft and Draft only badges on home page rows * fix: synthesize value wrapper on draft-only raw_app response * fix: tolerate missing latest-version on draft-only flow reload * fix: skip first observable change in DB sync effect to match LS persist * fix: remove URL-hash sync from script editor (already marked TEMP) * refactor: drop localStorage layer from UserDraft * refactor: drop vestigial LS-era code from UserDraft * feat: migrate localStorage drafts to DB on layout mount * fix: migrate session runtime + script view to per-user draft API * feat: add 'Reset to deployed' action on draft-loaded toast * feat: hide 'Reset to deployed' action when no deployed version exists * createCoalescingKeyedRunner * example ts doc * createDebouncerByKey * refactor: drop await on draft-delete in reset flows, refetch deployed directly * fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders * feat: route UserDraftDbSyncer.save through debouncer + coalescing runner * feat: add immediate-save bypass that cancels pending debouncer + runner tasks * fix: seed UserDraft cell from spec defaultValue on acquire * fix: redirect /add routes at load phase to eliminate white flash * fix: drop +page.js files in /add routes that conflicted with +page.ts * refactor: send draft as separate .draft field instead of deep-merging onto deployed * feat: surface draft path in home list when user typed one different from URL * feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init * fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath) * fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions * feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState * refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed * fix: gate per-user draft-only rows in listings on include_draft_only flag * feat: flush pending draft saves via keepalive fetch on tab hide / pagehide * autosave indicator nits * fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template * chore: add [draft-sync] console logs to trace script bootstrap autosave * fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation * fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore) * fix: poll script.path via tick() until Path widget settles before restartSync * chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast * fix: wait for script.path to stabilize across two ticks before restartSync * revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs * fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties * fix: heal legacy drafts with schema={} (no .properties) on deploy * autosave indicator * refactor(editors): drop UnsavedConfirmationModal mount + Show diff button * feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker - Other-users-drafts banner (Modal2): the deployed-overlay response now carries `other_drafts_users` (workspace usernames only, never emails); each row offers View JSON + Fork. Drops the standalone `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a workspace `username` query param (resolved to email server-side). - Cross-tab/browser save conflict detection: the syncer attaches `last_sync` to every save (defaults to non-force); on a `conflict` response it parks a snapshot in a reactive map. Each route mounts a `DraftSyncConflictModal` and seeds the per-tab `last_sync` via `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load. Keepalive flush also respects optimistic concurrency. - Raw app template picker re-added after the /add ⇒ /edit refactor: framework (React 19 / 18 / Svelte 5), data table + schema config, and optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and driven by `new_draft=true` on the edit route. * fix(drafts): suppress autosave during /add template seeding on script + raw app editors - ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores- ready so the Path widget's `$workspaceStore && $userStore`-gated `initPath → reset → onMetaChange → bind:path` cascade lands inside the suspension window. Two `tick()` waits weren't enough — the bind:path mutation fired ~100ms after the prior `restartSync` and posted as a "user edit". - apps_raw route: suspend autosave on `new_draft=true` and resume only after the framework picker closes (via `onStart` or X dismissal), with a two-tick settle so the picker's seeded `files/runnables/data/policy` mirror to `draftHandle.draft` observably advances `lastSerialized` before sync re-arms. * fix(drafts): land /add redirects on the real workspace username, not "me" The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt` populated `userStore`. `get(userStore)?.username` returned undefined and fell back to the `'me'` placeholder on every fresh nav, producing `u/me/draft_{uuid}` paths instead of the user's real namespace — broke ownership checks against `authed.username` and silently scoped autosaves under the wrong path. Layout now persists `username` to localStorage on every successful `getUserExt`, and `getUsernameForNamespace` (new shared helper, used by all four `/add/+page.ts` files) reads the live store first, falls back to the cached value, and only then to `'me'` for true first-ever loads. * fix(drafts): key low-code app autosave on the URL path, not the empty string `AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` — a legacy leftover from when `/apps/add` was its own URL (no path). With the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made autosaves land on the `('app', '')` row instead of the URL path: - The `apps/list?include_draft_only=true` query joins drafts onto `app.path`, surfacing drafts at the URL path. The empty-path row didn't match the user's URL so the draft never appeared in the home list. - Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL path with `?get_draft=true`, finds nothing, and 404s. Drop the ternary so the handle always uses `path` — the same as scripts/flows/raw_apps. The route's `?new_draft=true` branch already seeds the empty-template baseline, so there's no longer a "the draft sits under '' until first save" race to worry about. * fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes The picker mounted `` (one-way prop, not `bind:open`). When the user dismissed via X / Esc / click-outside, the inner Modal flipped its own local `open` to false (hiding the UI) but never wrote back to the picker's `open` $bindable. The route's `templatePicker → false` watcher — the one that calls `restartSync` two ticks after the picker closes — never fired, so autosave stayed suspended and the user's edits after dismissal were silently dropped. Switch the inner Modal to `bind:open` so the dismissal bubbles all the way up to the route's state. "Start without AI" already worked because its `onStart` handler explicitly sets the picker's `open = false`. * nit unused * fix(drafts): make the home-page View/Edit JSON action work on draft-only apps The "View/Edit JSON" entry on the home page called `AppService.getAppByPath` without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}` the backend 404'd with "App not found at path …". Pass `get_draft=true` and render the synthesized stand-in's editable shape: - App drafts come back as `{summary, value, path, policy, ...}` — `value` is the App definition the editor was working on; show that. - Raw-app drafts come back as the flattened `{files, runnables, data, summary, policy, ...}` with no nested `value`; show the whole shape. On save, draft-only items can't go through `updateApp` (no deployed row). Route the edit through `UserDraftDbSyncer.save` (with `immediate: true` so `await` resolves after the POST lands) and relabel the button "Save draft" + Save icon. Deployed items keep the existing "Deploy" flow unchanged. * fix(drafts): render the right shape in View/Edit JSON for draft-only items The previous fix landed `fapp.value` into the editor, but the deployed-overlay flattens the bare editable shape into `inner`/the top-level response — drafts have no nested `.value`. So: - App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered as empty (`fapp.value` was undefined). - Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag can't tell which draft kind to look up, defaults to `app`, doesn't find one. Thread the row's `raw_app` flag from AppRow → `appExport.open(path, rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable shape from `fetch_draft_only`) into the JSON editor for draft-only items — clean payload, no `is_draft` / `no_deployed` / overlay noise. Save the same bare shape back through the syncer so the regular editor reads it unchanged on the next mount. * fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired `AppService.getPublicSecretOfApp` immediately because the gating effect only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route plain-SELECTs `app.id`, so a draft-only path 404'd with "App not found at name …" and the public-URL ClipboardPanel spun forever waiting on `secretUrl`. Thread the existing `newApp` signal (already on `AppEditorHeader` / `RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch behind `!newApp`, and render the existing "Deploy this app once to get the public secret URL" placeholder instead of the spinner for draft-only items. * fix(drafts): disable Diff button on draft-only items across the 4 editors Diff has no baseline to compare against on draft-only items — the button used to be gated by the pre-PR `/add` route's own state, but the `/add → /edit` redirect landed everything under the regular `/edit` page where the gate was missing. - ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`; seed `no_deployed: true` on the route's `new_draft` empty NewScript so the gate fires before the first deploy. - FlowBuilder: gate the topbar Diff on `newFlow` (route already sets it from `backendFlow.no_deployed` and the new-draft branch). - AppEditorHeader: gate both the "Diff" dropdown action and the Deploy-drawer's "Diff" button on `newApp`. - RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff" button on `newApp`. Each gate also rewrites the tooltip ("Deploy this … once to compare against the deployed version") so the hover state explains why. * fix(drafts): disable the "No login required" toggle on draft-only apps Flipping the toggle called `setPublishState`, which POSTs the new `policy` through `AppService.updateApp` — that handler's `UPDATE app ... RETURNING path` finds nothing on a draft-only path and `not_found_if_none` 404s with "App not found at name …" (apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to deploy once before configuring the publish state. * refactor(drafts): drop dead draft_path field from list responses The draft-only listing branches in scripts/flows/apps computed a `draft_path` from the draft JSON (when the user-typed path differed from the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App} Row.svelte` preferred it over `path` for the row title. In practice that path is never written: the app, raw-app and flow editors all warn "Deploy the X to make the path change effective" — the rename only lands on deploy, never in the draft. So the field is always None and the home rows always show the autogenerated slot anyway. Drop the field from the three `Listable*` structs, the three draft-only push sites, the three OpenAPI response schemas, and the three frontend row components. Client regenerated. * fix(drafts): seed a friendly name on /flows/add The flow route passed `initialPath={page.params.path ?? ''}` to FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}` redirect the Path widget's `initPath` saw a non-empty `initialPath` and skipped the `reset()` branch that auto-generates the friendly `_flow` name. The other three editors all clear `initialPath` in their `new_draft` branch for exactly this reason. Track `initialPath` as route-owned state (defaults to the URL path) and clear it to '' inside the `new_draft` branch, then bind it through to FlowBuilder so any post-deploy update from the editor still propagates. * feat(drafts): render friendly user-typed path on home list for all 4 kinds Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}` URL slot, with two source rules — one per how each editor wires the Path widget: - Scripts already work: `ScriptBuilder` binds the Path widget directly to `script.path`, so the typed path round-trips through the draft JSON's own `path` field. Backend extracts `v["path"]` when it differs from `row.path`. - Flows / apps / raw apps don't write the typed path into the autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the bare `App` / raw-app value has no `path` field at all). Introduce an explicit `draft_path` field on the draft JSON, written by the editor ONLY when the typed path differs from the deployed/seeded `savedX.path`: - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`. - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`. - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the bind chain (RawAppEditor → route); the route's draftHandle.draft spread includes `draft_path` when set. Backend extracts `v["draft_path"]` and `None` when unchanged or after deploy (deploy clears the whole draft, so the field naturally disappears post-deploy without bookkeeping). Flow route's `new_draft` branch now stops sync around the Path widget cascade, with a 700ms scheduled `restartSync` (mirrors the existing scripts/apps/raw_apps stoppers) — the new draft_path mutation lands inside that window so `/flows/add` no longer fires an autosave before the user's first edit. openapi/sqlx regenerated. * fix(drafts): preserve the user-typed draft_path on reload of draft-only items The flow / app / raw-app editors all dropped the saved `draft_path` back to the URL's `u/{user}/draft_{uuid}` slot the moment the user reloaded a draft-only edit page: the route sourced the Path widget's initial path from `page.params.path` instead of the previously-saved `draft_path`, and the first user edit then mirrored that URL path back into the autosaved draft — silently overwriting the friendly name in both the row and the editor. - Flow route: after computing `effectiveFlow`, override `flowInitialPath` with `effectiveFlow.draft_path` when set. - App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}` through to `AppEditor`; AppEditorHeader's `newEditedPath` default now prefers a non-empty `newPath` over the random `_app` seed (the `newApp && !newPath` branch keeps the `/apps/add` friendly auto-name). - Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp` so the `extractRawApp` path seeds `newPath` with the friendly name. Reload + a subsequent edit now leaves `draft_path` intact for all three kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint. * fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls `document.querySelector(target)` — an empty selector throws "Failed to execute 'querySelector' on 'Document': The provided selector is empty" and the modal silently fails to mount. That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never appeared on editors where another user had a draft — both omit the `target` prop. Other Modal2 callers (StorageSettings, CriticalAlert, CustomInstanceDbWizardModal, …) pass an explicit `target="#content"` and were unaffected. Match Portal's own default of `'body'` so omitting the prop is now a no-op rather than a runtime throw. * fix(drafts): Reset to deployed no longer resurrects the draft The toast's "Reset to deployed" callback POSTed `value: null` to the syncer, then handed control to the route's `onResetToDeployed` (which wipes the in-memory handle and reloads the deployed payload via `getDraft: false`). Both writes flowed through the reactive sync effect: the wipe scheduled a delete, the reload scheduled a re-save of the deployed value as the new draft. Coalescing collapsed them and the draft came back — making the "discard" action effectively a no-op. Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The explicit `value: null` POST still goes through (it's a direct `UserDraftDbSyncer.save` that doesn't depend on the reactive effect), the route's wipe-then-reload mutations advance `lastSerialized` silently under suspension, and the next user edit (after two ticks past the deployed-seed write) is the first real save again. * ui nit * feat(drafts): autosave-indicator popover with Reset-to-deployed action Click the cloud icon → popover with "All changes are saved as a draft on the server. The draft is per-user — your teammates' editors keep their own." When the editor isn't on a draft-only path AND the user has a draft (UserDraft.has returns true), a "Reset to deployed" button mirrors the load-time toast action — stops sync, POSTs `value: null`, runs the route's reload-without-draft callback, restarts sync past two ticks so the deployed-seed write doesn't resurrect the draft. Threaded `onResetToDeployed` from each route down to its builder (ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader) and into the indicator. `draftOnly` is wired from `savedScript.no_deployed` / `newFlow` / `newApp` so the action hides where there's nothing to fall back to. The indicator's trigger now has a hover affordance + matches Portal's default target ('body') via Modal2's earlier fix. * fix(drafts): wait for the fork POST to land before navigating OtherUsersDraftsModal's Fork action called UserDraft.save, which routes through the autosave debouncer (1500ms). The subsequent goto fired within the same tick, so the destination editor's get_draft=true read ran before the POST landed and 404'd — refreshing worked because by then the debounced save had fired. Call UserDraftDbSyncer.save with immediate: true and await it. The syncer cancels any queued debouncer task for the key and resolves the promise only after the POST completes, so the route load can find the forked draft on the first try. * fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage Two tabs editing the same draft both load with last_sync = T0. Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1 into localStorage. Tab-2 then tries to save: it reads the SHARED localStorage map, sees T1 instead of its own baseline T0, sends last_sync = T1, and the backend's WHERE clause (`created_at <= last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing a conflict. Move the map to tab-local memory (`new Map`). Reload of the tab now starts with an empty map; that's fine because the editor's load path calls `recordRemoteSync(query, draft_saved_at)` right after `get_draft=true` returns, reseeding from the authoritative server timestamp before any user edit could fire a save. * fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON Two bugs in the per-editor "another user has a draft" banner: - Fork landed the immediate save but didn't close the banner before navigating. Svelte hadn't torn down the previous route's components by the time goto returned, so the banner lingered on top of the destination editor. Comment the explicit isOpen=false on the happy path so it's clear it MUST run before goto. - Clicking anywhere on the screen while the View JSON drilldown was open closed the underlying banner too. Modal2's clickOutside action fired on every Modal2 instance — both the JSON modal and the underlying banner — because both attach their own listener at the document level. Add `closeOnOutsideClick` opt-out on Modal2 and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so clicks outside the JSON drilldown only close the drilldown. Drive-by: Modal2's keydown handler now ignores Escape when its own isOpen is false (was a no-op closer that would still preventDefault on every key press, swallowing key events for any siblings). * fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped * fix(drafts): defer reset-to-deployed restart until first user interaction Two-tick `restartSync` was too aggressive: editor remounts emit a tail of cascading writes (Monaco setValue acks, schema re-infer, UI Builder iframe handshakes, schedule-config recomputes, …) that land well after two ticks and would clobber the just-deleted draft with an upsert of the deployed value — making "Reset to deployed" a no-op in practice, the user kept seeing the draft come back. Centralise the suspension lifecycle in a new `runResetToDeployed` helper. It stopSyncs around the reset, POSTs the explicit delete, runs the route's wipe-and-reload, and then arms a one-shot listener on document keydown / input / pointerdown that restartSyncs on the user's next real interaction. A 5-second fallback re-arms sync if the user walks away without touching the editor, so suspensions don't leak. Use it from both the load-time toast (`notifyDraftLoaded`) and the autosave-indicator popover so the two stay in sync — fixes both entry points. * indicator ui nits * fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change The single keepalive flush bound to both `visibilitychange → hidden` and `pagehide` self-conflicted on tab switch: visibilitychange fires on every tab/app switch with the page still alive, the keepalive POST advanced the server's `created_at` to a fresh `now()`, the client discarded the response (no listener), the local `lastSync` stayed at the old value, and the next foreground autosave sent that stale timestamp → server saw `created_at > last_sync` → conflict modal for the user's own background-tab write. A still-pending debouncer task made it worse: it fired a second runner POST after the keepalive with the same stale `last_sync`, the second self-conflicted too. Split into two paths: - `visibilitychange → hidden` → `flushOnVisibilityHidden`: route through the normal runner pipeline. The page is alive, so the response can land and `setLastSync` keeps the baseline current. Call `debouncer.cancel(key)` first so a queued keystroke can't double-fire with the same stale `last_sync`. - `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch for the genuinely-going-away case (the JS context is torn down, the response is necessarily discarded). Same `debouncer.cancel(key)` guard. On the next mount, the route's `recordRemoteSync(query, draft_saved_at)` reseeds `lastSync` from authoritative server state before any user edit can fire a save. * fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs Tab switching just hides the page; the JS context survives and the debouncer's `setTimeout` keeps counting down. When it fires, the runner POSTs normally and the server's response updates `lastSync`. There's nothing left for a visibilitychange-driven flush to do that the ordinary pipeline doesn't already handle, and adding one only creates extra POSTs to reason about. `pagehide` remains the single trigger for the keepalive flush — that's the case where the JS context is actually being torn down and the runner's pending fetch would otherwise be killed mid-flight. * nit * refactor(drafts): drop LS-era pipeline; backend is canonical on load The PR's iteration left behind a meta/staleness pipeline carried over from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a 'Restored from local storage' toast, and a localDraft-vs-backend comparison branch in every editor loader. With drafts now living in the DB and the optimistic-concurrency lastSync check handling divergence, that whole stack is dead weight. Worse, the comparison branch caused 'Load from server' in the conflict modal to do nothing: the loader preferred the in-memory cell over the backend, so the user-clicked 'load from server' just re-displayed the local edits AND fired two confusing toasts (Restored from local storage + Loaded your saved draft). The rip: * userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta, checkStaleness, UserDraftStalenessCause, normalizeForCompare, localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta, handle.meta/setDraftAndMeta/setMeta, force option. Handle is now just { draft }. * userDraftToast.ts: drop notifyRestoredFromLocal + RestoreFromLocalActions. Update copy. * LocalDraftStaleModal.svelte: deleted. * AppEditor.svelte: drop initialRevs prop and the firstMirror wipe-then-restore dance (it existed only to consume the meta-mismatch skip slot). * All 4 editor routes: backend is canonical on load — the in-memory cell is overwritten with the deployed+draft overlay, the syncer's seed guard swallows the first write so we don't POST it back. * VariableEditor / ResourceEditor: drop the staleness pipeline + rev bookkeeping; backend wins on open. * useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual utility as a private cfgDiffers helper (kept for the form-vs-deployed dirty check, which is a genuine semantic compare, not LS legacy). * copilot core.ts / userDraftAdapter.ts: drop meta argument from saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on getMeta dropped. Net: -22 typecheck errors, fewer moving parts, conflict modal works. EOF ) * refactor(drafts): remove dead endpoints + UserDraftDbSyncer.getLastSync The list_drafts and get_draft (own) routes were added during PR iteration and never wired up to any frontend caller — the editor overlay path uses the per-kind get-by-path getDraft query parameter, and the home page lists drafts via the per-kind list endpoints, not via /drafts. Drop both routes (+ sqlx caches + OpenAPI entries). UserDraftDbSyncer.getLastSync was a peep-hole for callers that never materialised — the per-tab lastSync map is only ever read by postSave internally, where the bookkeeping already lives inline. * refactor(drafts): extract DraftEditorModals trailer block The four editor routes (scripts/flows/apps/apps_raw) mounted an identical pair of trailer modals — DraftSyncConflictModal + OtherUsersDraftsModal — wrapped in the same guard chain and {#key path} remount. Lift the markup into one component; routes thread their itemKind, path, editPathFor, and loader callback. Pure markup extraction, no state ownership change. Drops the unused userStore import where the trailer was the only consumer. * refactor(drafts): UserDraft.useReactive — kill array-of-one boilerplate The script + flow routes both wanted a handle that re-keys when the URL path changes. UserDraft.use() can't do that (its opts getter is untracked), so each route hand-rolled the same useMany-array-of-one + proxy idiom: const handles = useMany(() => [{ kind, path: reactive }]) const handle = { get draft() { return handles[0]?.draft }, ... } Add UserDraft.useReactive(getSpec) that internally wraps useMany with a single spec and returns the stable proxy. Callers collapse to one line. * refactor(drafts): unify bootstrap suspension via armRestartOnFirstInteraction The flow and raw-app routes each rolled their own end-of-bootstrap resume: a 700ms setTimeout for flows and a templatePicker watcher with double-tick gating for raw-apps. Both are timing-fragile (the comments admit it) and drift from each other. armRestartOnFirstInteraction already existed in userDraftToast.ts for reset-to-deployed: keydown/input/pointerdown listeners (capture phase) that fire restartSync on the first real user touch, with a 5s belt-and-braces fallback. Export it and use it everywhere we'd previously have picked a magic number. For raw-apps this is a tiny behavioural change: the user's template choice now POSTs immediately (the pointerdown that picks the template also resumes sync, so the picker's onStart write rides the wake-up). Previously the choice only persisted on the user's NEXT edit. That's strictly better — navigating away preserves the choice now. * refactor(drafts): type App.draft_path; drop the as-any cast The audit asked for the three editors to converge on one draft_path injection pattern. For App and Flow, the in-builder $effect-mutates- the-store idiom is wedged into a shape that doesn't natively own the field — App's editor type genuinely has no draft_path so the writer had to cast through `as any`, and consumers downstream did the same. The minimum viable fix: declare draft_path on the local App type (it's already a field on the autosaved JSON). Lifting the writes upward into a route-side merger would mean restructuring the AppEditor mirror $effect and the FlowBuilder pathStore plumbing — larger change for the same shape, deferred to a follow-up. Flow already has the typed cast localised at one site. Will get the OpenAPI-level draft_path field as part of task 47 (drop as-any casts on backend overlay reads). * refactor(drafts): extract makeDraftAddLoad helper Four identical /add/+page.ts files differing only by the edit-route prefix. Lift the redirect into a factory, slim each entry point to two lines. * refactor(drafts): type UserDraftOverlay.other_drafts_users in the OpenAPI The backend response carried other_drafts_users on every get-by-path that supports the draft overlay, but the OpenAPI schema didn't declare the field. Each route had to cast the typed response to `any` to read it (and the sibling draft_saved_at), which obscured the real shape from the type system and rotted the discoverability of the draft surface. Add it to UserDraftOverlay. Frontend casts collapse to plain property reads in the three editor routes. * feat(drafts): list & open draft-only items for variables, resources, schedules, triggers For scripts/flows/apps the list and get-by-path endpoints already surface per-user drafts that have no deployed counterpart — that's what gates the home page from 404'ing on an AI-agent-created draft. Extend the same support to the other UserDraftItemKinds: Backend (list endpoints): - Add include_draft_only to ListVariableQuery, ListResourceQuery, ListScheduleQuery, StandardTriggerQuery (the latter covers the 11 trigger kinds via the generic TriggerCrud). - Append per-user draft rows whose path has no deployed row. Same gate as scripts/flows/apps: non-operators, page 0, no narrowing filters. Synthesis is per-kind: ListableVariable/Resource get field-for-field synthesis; ScheduleLight reads NewSchedule shape; Trigger uses a best-effort JSON merge + serde_json::from_value (rows skipped on deserialize failure rather than failing the list). - Add draft_only: Option with sqlx(default) to each row type so it serializes as the column is opt-in. Backend (get-by-path endpoints): - get_variable, get_resource, get_schedule, get_trigger fall back to fetch_draft_only when the deployed row is missing and the caller passed get_draft=true. Mirrors scripts/flows/apps. OpenAPI: - Shared IncludeDraftOnly parameter under components/parameters, wired into the 11 trigger list endpoints + listRawApps. Inline declarations on listVariable / listResource / listSchedules / listAzureTriggers. - draft_only field on ListableVariable, ListableResource, Schedule, TriggerExtraProperty. Frontend: - variables, resources, schedules, and the 10 trigger list pages (routes + 9 *_triggers) pass includeDraftOnly: true on the initial fetch and render on synthesized rows. Trigger pages got a sed/perl bulk update — pattern is the same across kinds. * fix(drafts): swap crypto.randomUUID() for the project's randomUUID helper crypto.randomUUID() is gated on a secure origin (HTTPS or localhost). Self-hosted Windmill instances often run on a bare HTTP origin or a LAN IP where the WebCrypto API is unavailable, so the /add redirect would throw before issuing the 307. Use the existing RFC4122 v4 helper in FlowChatManager that the rest of the codebase already imports for this exact reason. * fix(editor): leading-edge fire + max-wait cap on Monaco debounce The Editor debounced `onDidChangeModelContent` purely on the trailing edge — every keystroke rescheduled a 500ms timer, and uninterrupted typing held the bindable `code` prop stale until a pause. Stacked behind our 1.5s autosave debouncer that meant our clock didn't even start ticking until 500ms after the user paused, and the `code` binding never updated mid-burst for downstream consumers (lint, live preview, change listeners). Switch to leading + trailing + max-wait: * First keystroke of a burst fires `updateCode` synchronously, then stamps a wall-clock chain start. * Each subsequent keystroke (re)arms a trailing timer at `min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap is what makes continuous typing materialize at least once per maxChangeTimeout window instead of indefinitely. * When the trailing fires it resets the chain so the next keystroke after a pause is a fresh leading fire. New prop `maxChangeTimeout` (default 1000ms) sits next to the existing `changeTimeout` (default 500ms). Dispose path clears the chain stamp alongside the timer. * feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately Each builder already had a Ctrl/Cmd+S keybinding routed through a saveDraft() no-op left over from the LS-era — the comment said "persistence happens via the page-level UserDraft autosave" but the shortcut was the user's only way to actually force a save without waiting for the 1.5s debounce. Restore the intent. * UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method that re-submits whatever's queued in pendingSaveOpts with immediate: true. No-op when nothing's pending. * Editor.svelte.flushPendingChanges() — exposes a synchronous updateCode() with chain reset, so callers can drain Monaco's own trailing debounce before asking the syncer to flush. Without this step a Ctrl+S within ~500ms of typing would POST the pre-burst content. * ScriptBuilder.saveDraft() — editor?.flushPendingChanges() → await tick() → UserDraftDbSyncer.flush(). Toast on result. * FlowBuilder.saveDraft() — no direct Monaco ref (flows have many per-module editors); just flushes the syncer. Editor.svelte's new 1s max-wait cap means at most the last <1s of typing in a module Monaco won't be in this POST; it follows in the next autosave round. * RawAppEditor.handleKeydown — adds a 's' case that flushes before the focus guard, so the shortcut fires regardless of where focus is in the editor pane. * fix(drafts): low-code apps — drop spurious autosave on /edit + remount on Load from server Two bugs in low-code app editor (raw apps use a separate code path): 1. Every /edit visit looked like an autosave because loadApp() called UserDraft.discard('app', path, undefined). The comment claimed "this load doesn't POST" but discard always POSTs value: null server-side — that surfaced as a DELETE-my-draft on every page load AND a flash in the AutosaveIndicator. The discard was originally intended to wipe the in-memory cell so AppEditor remounts "fresh". But the path-change $effect upstream already sets app = undefined before each loadApp, which unmounts AppEditor and releases the handle's entry — so a remount via app = backendApp naturally starts with an empty handle. Drop the discard. 2. The conflict modal's "Load from server" called loadApp() but didn't remount AppEditor. Since AppEditor's stateApp is captured once at mount and doesn't react to prop changes, the editor kept showing the conflicting local edits even after a successful reload. Wrap the onLoadFromServer to await loadApp() then bump redraw to force a fresh mount. * feat(drafts): home-page Draft badge — show user-initial circles, drop the '+' The home-page Draft badge previously showed '+Draft' as a flat label. Add per-user awareness: up to 3 user-initial circles render to the left of the label, ordered alphabetically; with 4+ users we collapse to the first 2 + a '+N' overflow circle so rows stay compact. Backend: * New `DraftUserRef { username: Option }` in windmill-types::user_drafts, re-exported from windmill-common so the list endpoints in scripts/flows/apps crates share one import path (windmill-types/windmill-common can't be reordered without a cycle). * ListableScript / ListableFlow / ListableApp gain a `draft_users: Option>>` field. The list SQL adds a per-row subquery `SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that aggregates the workspace users with a per-user draft at this path. NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets orphaned drafts (user removed from workspace) still surface with username = None. * Synthesized draft-only rows set draft_users to a single-element vector with the authed user (those rows come from `email = $2`). OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp response shapes as an array of `{ username }` with nullable username. Frontend DraftBadge: * Accepts `draft_users: { username?: string | null }[]`. Renders up to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 + a gray '+N' overflow circle. * Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy NULL-email row → '?'. * Color picked deterministically from a 6-entry palette so the same user gets the same circle color across rows. * Label is now just 'Draft' (dropped the '+'). 'Draft only' is unchanged. * Tooltip lists every user in full. ScriptRow / FlowRow / AppRow thread `draft_users` through their prop types and pass it to DraftBadge. * fix(drafts): suppress 'You have unsaved changes' banner when deployed baseline is null A brand-new variable/resource/trigger (no deployed row yet) has `getDeployed() == null`, but the caller's `show` prop is computed off `current != deployed` which is trivially true while the user types. Result: the banner appeared with 'Show diff' (no-op — the drawer early-returns on null deployed) and a 'Discard' that's semantically backwards (there's nothing to revert to). Gate `show` internally on `getDeployed() != null`. The check sits in the banner rather than each caller because every caller would otherwise need the same boilerplate guard. * fix(drafts): hide LocalDraftBanner when deployed and current match the DiffDrawer's compare Earlier I gated the banner on `getDeployed() != null`, but the user still saw it fire on entries where 'Show diff' opens to 'No changes detected'. That means `show` (the caller's coarse dirty check) flagged a difference the DiffDrawer treats as a no-op — typically toggle defaults (`false ↔ undefined`), removed empty arrays, or key-ordering noise that `cleanValueProperties + orderedYamlStringify` collapses. Replicate the drawer's comparison inside the banner: stringify both sides through the same pipeline and only render when the keys differ. A single `diffKey()` helper keeps the logic local; the catch-and-empty fallback survives a non-serializable side rather than throwing. * ui(drafts): nest user-initial circles inside the Draft badge Previously the circles sat alongside the Badge in a parent flex container; the result read as two separate UI elements. The Badge component already exposes its children as a snippet rendered inside its own flex row, so moving the circles into it makes them feel like part of the same chip. Knock-on tweaks: shrunk the circles from h-4/w-4 to h-3.5/w-3.5 so the badge stays compact, and tinted each circle's ring with the badge's indigo palette (instead of plain white) so the overlap reads as a deliberate stack rather than dots floating on top of the chip. * feat(drafts): drop the authed user's circle, mark own drafts with a '*' suffix Three tweaks to the home-page Draft badge: 1. Filter the authed user out of `draft_users` before rendering circles. The row already signals 'this user has a draft' via the asterisk (below), so a circle for them would be redundant noise. New `currentUsername` prop on DraftBadge — pass `$userStore?.username` from each row. The tooltip still lists every user (with `(you)` next to the authed one) so the full picture is one hover away. 2. The badge already showed whenever `is_draft || draft_users.length > 0` (per-user OR any-user). Spelled the rationale out in a comment — no logic change. 3. Append '*' to the displayed summary when `is_draft` is true. Falls back to `draft_path`/`path` when summary is empty so the marker never decorates an empty string. Threaded the same expression into ScriptRow / FlowRow / AppRow. Slice/overflow math now keys on the post-filter `otherUsers` list, so dropping the authed user doesn't silently shrink the visible count (e.g. 3 users incl. self → 2 circles, not 1 circle + a '+1' bubble). * feat(drafts): clone per-user drafts when forking a workspace `clone_workspace_data` clones every other workspace-scoped table on fork creation (resources, variables, scripts, flows, apps, raw apps, triggers, schedules) but quietly dropped the `draft` table. With per-user drafts that meant any open editor in the parent lost its pending edits the moment a fork was created — surprising and inconsistent with how forks treat the deployed surface. New `clone_drafts` mirrors the existing clone helpers: a single INSERT...SELECT into the target workspace, preserving `path`, `typ`, `value`, `created_at`, and `email`. The `email` FK targets `password.email` which is instance-scoped so it carries across workspaces without remap. `created_at` is preserved on purpose so the per-tab `last_sync` baseline lines up with the parent's timeline — otherwise the fork's next autosave would race a stale `last_sync` and trip the conflict modal on every cloned draft. Plain INSERT (not UPSERT) is safe because the fork target is empty at create time; no conflict against the partial unique indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic BIGSERIAL `id` PK is regenerated by the default so it stays out of the column list. * ui(drafts): pin the authed user to the first circle instead of hiding them Previously the authed user was filtered out of the circle row entirely on the theory that the row's '*' suffix already signalled 'this user has a draft'. New requirement: they should always lead the circle row when they have a draft so the visual half of the signal lines up across rows (consistent leading-slot identity, easy scan). Switch from a filter to a sort: `orderedUsers` finds the authed user in `draft_users` and splices them to index 0; everyone else keeps the backend's alphabetical order behind. Slice/overflow math now keys on `orderedUsers`, which guarantees the authed user never falls into the '+N' bubble — they're at position 0 and the slice keeps the head. The popover's '(you)' annotation moves to the circle's title attr too, so hovering the leading circle confirms the identity. * feat(drafts): drop draft_only column from script/flow/app Drafts now live in the `draft` table exclusively — `draft_only` stubs in script/flow/app are redundant. Migration `INSERT INTO draft ... ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING` so real per-user drafts already at the same path are preserved; only rare stubs that lost their draft get a synthesised workspace-level row. Stubs are then deleted (FKs cascade to *_version) and the column is dropped. List endpoints keep a synthesised `draft_only: true` on rows sourced from the draft table itself (sqlx default on the struct field). Co-Authored-By: Claude Opus 4.7 * ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal The "Loaded your saved draft" toast and the auto-opening OtherUsersDraftsModal both surprised users on every editor mount. Move both signals into the AutosaveIndicator label: "Loaded from draft" or "Others are working on this {kind}" (priority) sits where Saving/Saved do, with a one-shot light-green flash behind the indicator that fades to transparent. Saving/Saved still win when they fire. The popover gains a "See others' drafts" button that flips the modal open on demand; the modal itself is now externally controlled via a bindable \`isOpen\` threaded through DraftEditorModals. Co-Authored-By: Claude Opus 4.7 * ui(drafts): per-user View JSON / Fork actions in DraftBadge popover Hover popover used to be a plain text list of usernames. Now each row gets a colored circle icon + name + "(you)" for the authed user, and every OTHER user's row carries View JSON / Fork buttons mirroring the OtherUsersDraftsModal. For draft-only entries owned solely by the authed user, the popover ends with "Only you can see this {kind}" so the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread workspace + itemKind + path + editPathFor through; AppRow switches between app / raw_app on app.raw_app. Co-Authored-By: Claude Opus 4.7 * nit * fix(drafts): clone only the forker's per-user drafts on workspace fork clone_drafts copied every user's drafts, but only the forker gets added to the fork's usr table. Drafts owned by absent users LEFT-JOIN to NULL in the home page's draft_users aggregate, surfacing as multiple legacy-style rows at one path and crashing the popover with each_key_duplicate. Filter the clone to email = forker OR email IS NULL, and key the popover's #each by index defensively so future legacy collisions can't crash the page either. Also re-adds `draft_only: None` to NewScript/CreateFlowBody literals in tests — the auto-generated windmill-api-client still carries the field and the previous commit dropped them too aggressively. Co-Authored-By: Claude Opus 4.7 * fix(drafts): always populate other_drafts_users in maybe_overlay_draft Reset-to-deployed reloads the deployed payload with get_draft=false, which made the backend return other_drafts_users=[]. The route then reassigned otherDraftsUsers to the empty list, dropping the count to 0 and hiding "See others' drafts" in the AutosaveIndicator popover — but the other users' drafts hadn't actually gone anywhere. Fetch the list independently of get_draft so the popover stays accurate across reset reloads. Co-Authored-By: Claude Opus 4.7 * feat(drafts): alert user when their draft is older than the latest deploy Open a modal on editor mount when the per-user draft was saved before the latest deploy at the same path — i.e. a teammate deployed a new version while this user's draft was sitting. Two choices: discard the stale draft and pick up the deploy, or keep editing the older draft. DraftEditorModals computes the staleness from the timestamps each route threads in (script.created_at, flow.edited_at, app_version.created_at) and the "Load latest deploy" callback reuses the route's existing reset-to-deployed logic. Wired for script / flow / app / raw_app editors; trigger / resource / variable drawer editors follow a different pattern and aren't covered here. Co-Authored-By: Claude Opus 4.7 * fix(drafts): deploy only wipes the deployer's draft, not everyone else's Script / flow / app deploys ran an unconditional DELETE on every draft at the path, so a teammate's deploy silently destroyed any other user's pending draft. After the wipe, the other user's tab kept auto-saving — re-creating the row at a NOW timestamp newer than the deploy — and StaleDraftModal never fired because draft_saved_at had been bumped past the deploy. Filter the DELETE to email = deployer (plus the legacy NULL row), so other users' drafts persist and the stale-draft prompt actually fires on their next reload. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface save failures in AutosaveIndicator instead of pretending Saved postSave caught network errors with `console.error` and let the runner finish normally. The indicator read the saving → none transition as a successful save and flashed "Saved" even when the request had thrown. Track failed keys in a SvelteMap, expose `'failed'` as a new UserDraftSyncState, render "Save failed" in red with a CloudOff icon. Failure clears on the next successful save for the same key, or when recordRemoteSync seeds a fresh authoritative timestamp. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface 'Save failed' inside the AutosaveIndicator popover too The popover used to repeat the cheerful "All changes are saved as a draft on the server..." copy even when the inline label said "Save failed", which read as contradictory. Add a red, text-xs warning at the top of the popover body when the sync state is `failed`, explaining that the latest edits didn't reach the server and that editing again retries the save. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface the actual error message in the AutosaveIndicator popover Replace the generic "your latest changes did not reach the server" copy with the real failure detail. The syncer now stores the extracted message in the failures map (formatSaveError walks body / message / statusText) and exposes it via the state handle's `failureMessage` getter. Popover renders it in red, monospaced, scrollable so a long server traceback doesn't blow out the popover. Co-Authored-By: Claude Opus 4.7 * fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard A `value: null` POST is a discard, not a save, but it ran through the same runner the indicator watched — so resetting to deployed flashed "Saving..." → "Saved", reading as "your draft just landed" while we were actually wiping it. Track in-flight discards in a SvelteSet, expose a distinct `'discarding'` UserDraftSyncState, and the indicator stays quiet for it: no spinner, no label change, and the `discarding → none` transition deliberately skips the "Saved" flash. Co-Authored-By: Claude Opus 4.7 * Revert "fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard" This reverts commit 625a47c5d290ff13ab35cd93db705c3e9bd21f6e. * fix(drafts): flush pending autosaves when the editor hook unmounts SPA navigation doesn't fire `pagehide`, so a debounced edit (up to maxDebounceMs old) silently disappeared when the editor was unmounted mid-typing. `UserDraft.useMany`'s onDestroy now walks every acquired entry and fires `UserDraftDbSyncer.flush(query)` before releasing, re-submitting the pending opts with `immediate: true`. The POST rides the runner's own lifetime and survives the component teardown. `use` / `useReactive` are thin wrappers around `useMany` so they inherit the flush automatically. Editors that don't go through the hook (sessions' `ScriptEditorView`, `AppJsonEditor`, copilot adapter, DraftBadge fork action) only call `UserDraftDbSyncer.save` for one-shot operations and don't need lifecycle flush. Co-Authored-By: Claude Opus 4.7 * nit * feat(ui): Modal2 fixedHeight='adaptive' sizes the modal to its content The fixed-height steps force either wasted whitespace or clipped content for small dialogs. `adaptive` emits no height rule (still capped by max-h-screen-80) so the modal hugs its content. Use it in StaleDraftModal, which only has two lines of copy and a button row. Co-Authored-By: Claude Opus 4.7 * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_ paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 * fix(drafts): dedupe app list rows when a path holds both app and raw_app drafts The apps list LEFT JOINed draft with typ IN ('app', 'raw_app') for the is_draft flag — a path holding BOTH kinds for the same user (easy to hit: open a raw-app draft path in the regular app editor and its autosave writes the second kind) fanned the row out into two identical entries and crashed the home list with each_key_duplicate. Join a DISTINCT (path, workspace_id) subquery instead. Same dedup for the draft-only synthesis block via DISTINCT ON (path) keeping the most recently saved kind. Co-Authored-By: Claude Opus 4.7 * feat(drafts): asterisk on resource/variable/schedule/trigger rows with own draft Add an is_draft flag to ListableVariable / ListableResource / ScheduleLight / BaseTrigger list rows — a scalar EXISTS subquery on the draft table for the authed email (no join, so no row fan-out), plus is_draft: true on the synthesized draft-only rows. The list pages (variables, resources, schedules, all trigger kinds) append `*` to the displayed name when set, mirroring the home page's convention. Also fixes draft-only resources never appearing on the resources page: the page always lists with resource_type_exclude=cache,state,app_theme (its tab split) and the synthesis gate bailed on any type filter. The gate now keeps synthesizing and applies resource_type / resource_type_exclude per-row against the draft JSON instead. list_triggers (trait default) takes an authed_email: Option<&str> — Some from the list endpoint, None from workspace export. Co-Authored-By: Claude Opus 4.7 * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commit 1f244a2a8b0407781e5f703b5927d03cf567c76c. * fix(drafts): P1 hardening — save authz, secret scrubbing, hot-path index 1. save_draft had no authorization check (a regression from the old create_draft's require_writer_of_path): any workspace member could plant drafts in another user's u/ namespace or unwritable folders, and those drafts get surfaced to every reader of the path (home circles, others'-drafts modal, View JSON / Fork). New require_can_write_path: admins; own u/ namespace; g/ namespace when in the group; f/ folders with the write/owner bit (with the same folder-claim refresh deploy endpoints use). Operators are rejected outright — they're excluded from every other draft surface. 2. Secret variable values were persisted in the draft table in plaintext. save_draft now blanks variable.value for is_secret drafts at write time (the editor never round-trips secret values anyway — it fetches with decrypt_secret=false), and a migration scrubs rows persisted before the guard. 3. fetch_other_drafts_users runs on every get-by-path request with (workspace_id, path, typ) and no email predicate — neither partial unique index covers it, so it seq-scanned a table that accumulates per-user autosaves across all workspaces. Add a plain btree index; it also serves get_draft_for_user's IS NOT DISTINCT FROM lookup. Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S flush narrates via the indicator, not a toast The "Draft saved" toast fired even with the network down — flush never rejects (postSave catches errors internally and routes them to the failures map), so the success branch always ran. Drop the toasts from the script / flow / raw-app Ctrl+S handlers; the AutosaveIndicator already narrates the flush truthfully (Saving... → Saved / Save failed in red). Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S always flashes Saved in the indicator After dropping the toast, an explicit Ctrl/Cmd+S with nothing pending (the common case — autosave already landed everything) gave zero feedback: flush() no-ops when pendingSaveOpts is empty and no state transition fires. flush() now bumps a reactive per-key counter on completion (no-op path included), exposed as flushCount on the state handle; the AutosaveIndicator flashes "Saved" on the bump when the pipeline is idle. Real flushes keep narrating through Saving... → Saved / Save failed as before. Co-Authored-By: Claude Opus 4.7 * ui(drafts): Ctrl/Cmd+S replays the green backdrop flash on the indicator Decouple the one-shot light-green → transparent backdrop from the load hint label: triggerFlash() owns the keyed span (mounted only while the animation runs), and both the on-mount hints and the Ctrl/Cmd+S confirmation route through it. The flush bump fires after the POST lands, so a real flush flashes too — not just the no-op path. Co-Authored-By: Claude Opus 4.7 * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_ paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S reaches the raw-app flush from every editor surface The raw-app window keydown handler never fired in practice: the file editor is a VS Code workbench in a same-origin iframe (keydowns don't cross documents) and the inline-script / YAML Monacos swallow Ctrl+S via addCommand. Two hooks: - attach a capture-phase keydown listener inside the iframe document on each load (no preventDefault — VS Code's own save still runs, we flush the pending autosave alongside it); - Editor.svelte / SimpleEditor.svelte re-broadcast their swallowed Ctrl+S as a `wm-monaco-save-shortcut` window event, which RawAppEditor listens for. Co-Authored-By: Claude Opus 4.7 * fix(drafts): editing a draft-only item opens create mode prefilled from the draft Variable / resource / schedule / trigger editors treated every loaded path as deployed and routed saves through the update endpoints, which 404 for draft-only items ("Resource not found at name ..."). The get-by-path responses already mark the case (`no_deployed` from fetch_draft_only) — editors now flip to create mode when it's set: - VariableEditor / ResourceEditor: existedInitially = !no_deployed - ScheduleEditorInner + all 10 trigger editor inners: loadTrigger / loadSchedule return { overlay, noDeployed } and openEdit sets edit = !noDeployed The form opens prefilled from the draft and deploys via create, whose endpoints already delete the creator's draft on success. (The "Could not load schedule: Not Found" half of the report was a stale dev backend — getSchedule?get_draft=true verified working on the current build.) Co-Authored-By: Claude Opus 4.7 * fix(drafts): leading-edge draft saves for raw apps (no double debounce) Raw-app file changes reach the parent already coalesced — the UI Builder iframe holds a ~1s trailing debounce on its rebuild and only posts setFiles when it fires. The syncer then stacked its own 1.5s trailing window on top, so the draft landed ~2.5s after the user stopped typing. The debouncer now supports a leading edge (run immediately when the key is idle and cooled down; later schedules in the window coalesce trailing with the max-wait ceiling, mirroring the classic editor's first-keystroke-materializes-immediately logic), and raw_app saves opt into it. The app build keeps its own trailing debounce inside the iframe — only draft persistence is affected. Co-Authored-By: Claude Opus 4.7 * ui(drafts): blue flash for load hints, green for save confirmations The backdrop flash now carries meaning: green = "your save landed" (Ctrl/Cmd+S), blue = informational on-mount hints ("Loaded from draft", "Others are working on this ..."). Color is passed as an inline CSS custom property the keyframe reads, so the single keyframe serves both variants. Co-Authored-By: Claude Opus 4.7 * Revert "fix(drafts): leading-edge draft saves for raw apps (no double debounce)" This reverts commit 1b996fd73afac1b65750cac49d24b332726bf25f. * feat(drafts): 'Enable auto-save' toggle in the AutosaveIndicator popover Browser-wide preference (default on, persisted in localStorage). While off, the reactive keystroke mirror never POSTs — saves marked `auto: true` park their latest opts in pendingSaveOpts instead of scheduling, and the unload keepalive flush is skipped, so nothing leaves the tab except explicit actions: Ctrl/Cmd+S flush (sends the parked latest content), discard / reset-to-deployed, fork, conflict overwrite. The indicator shows a muted cloud-off while disabled (the idle check-mark would otherwise read as "everything saved") and the popover copy explains the Ctrl/Cmd+S-only behavior. Re-enabling re-schedules every parked unsaved draft so edits made while off catch up immediately. Co-Authored-By: Claude Opus 4.7 * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commit fd7013b399b55c7609624f3e373e9dc68f6760f3. * feat(drafts): Review & Deploy covers variables/resources/schedules/triggers The drafts review page only assembled scripts/flows/apps from three paginated list endpoints, so drafts of every other kind were invisible. New GET /w/{ws}/drafts/list returns every draft of the authed user in one query over the draft table, with a per-kind draft_only flag (deployed-table EXISTS per kind); getDraftItems switches to it, which also drops the 3×N-page fan-out. CompareDrafts renders the new kinds (icon via a UserDraftItemKind → layout-Kind mapping, gray kind badge, list-page edit links for drawer-based editors), diffs them through a generic overlay GET, and deploys them by replaying the editor save: create/update for variables and resources, saveScheduleFromCfg for schedules, the per-kind save*TriggerFromCfg helpers for the ten standalone trigger kinds. Also fixes two paths stale since the draft_only column removal: draft-only flows/apps now deploy via create (update 404s — there is no row anymore), and discard always deletes the draft row (the old delete-the-item branch 404'd for the same reason). Co-Authored-By: Claude Opus 4.7 * feat(drafts): optimistic asterisk while editing in list-page drawers The `*` suffix on variable/resource/schedule/trigger rows came from the server's is_draft flag, which only updates on a refetch — editing an item in the drawer didn't mark its row until much later. New localDraftHints module (SvelteSet-backed): editors publish their dirty state (the same condition that shows the "You have unsaved changes" banner) and the 13 list pages OR the hint into the asterisk condition, so the suffix appears the moment the form diverges and clears on discard/teardown. Wired once in useTriggerDraftSync (covers the schedule editor and all ten trigger editors) plus VariableEditor and ResourceEditor. Co-Authored-By: Claude Opus 4.7 * ee repo * fix(drafts): draft hints persist past editor teardown, re-sync on reopen Clearing the optimistic asterisk on drawer close was wrong: the divergence the editor observed is autosaved server-side, so the draft outlives the drawer and the asterisk should too. Hints are now corrected rather than expired — while an editor is settled on an item it publishes the observed truth in both directions (divergence sets, sitting at the deployed baseline clears), so a draft discarded from another tab loses its stale asterisk the next time the item is opened. No teardown cleanup anywhere. Co-Authored-By: Claude Opus 4.7 * fix(drafts): list-page asterisk mirrors the editor's banner, not stale is_draft The asterisk was `is_draft || hint` — an OR can turn the asterisk on optimistically but can never turn it OFF, so after discarding a draft (or editing back to the deployed value) the stale server flag kept the asterisk until the next list refetch. Make the local hint a tri-state override instead: the editor publishes the live banner state (true/false) into a SvelteMap, and the list pages read `getLocalDraftHint(...) ?? is_draft` — the editor's observed truth wins over the stale server flag in both directions. Co-Authored-By: Claude Fable 5 * fix(drafts): autosaves equal to the deployed value delete the draft instead When the user edits back to exactly the deployed value, the reactive autosave mirror used to persist a baseline-equal copy — a useless draft row that kept `is_draft` (and the list asterisk) on after refetch. Add a `discardIfEqualTo` baseline getter to `UserDraft.useMany` specs: when the cell's value deep-equals the deployed baseline, the mirror POSTs `value: null` (delete) instead of the value. The variable and resource editors pass their `initialStates` baseline, guarded on `existedInitially` — draft-only/new items have no deployed copy, so equality must never delete their only data. Co-Authored-By: Claude Fable 5 * Draft encryption for secret variables * fix(drafts): discardIf predicate + deploys clear the asterisk and draft row Two follow-ups on the baseline-equal-autosave-deletes change: 1. `discardIfEqualTo` (baseline getter + raw deepEqual) becomes `discardIf` (predicate). Raw deepEqual reported spurious diffs after a refresh: drafts round-trip through JSON, which strips undefined-valued keys, so a restored draft (`{}`) never compared equal to the freshly built baseline (`{ labels: undefined }`) and the delete never fired. The editors now pass the SAME comparison that drives their "unsaved changes" banner — a new exported `draftValuesEqual` (JSON-normalized deep equality) used by both — so the banner and the synced draft can never disagree. 2. Truly saving (deploying) clears the asterisk and the draft row: - variable/resource editors: replace post-deploy `UserDraft.remove` (blanks the cell to `undefined`, which reads as dirty and keeps the banner + asterisk on) with `discard` to the just-saved state, and refresh `initialStates`/`existedInitially` so the editor settles clean. - trigger editors: `useTriggerDraftSync.discard` publishes the hint off explicitly — after a deploy the editor's `deployed()` baseline is stale, so the hint effect alone would keep the asterisk on. - Review & Deploy page: `deployDraft`/`discardDraft` clear the hint. Co-Authored-By: Claude Fable 5 * revert encryption just for the resources part * fix(drafts): required const DRAFT_KIND on TriggerCrud; deploy/delete cover raw_app The TriggerCrud::user_draft_item_kind() default matched on TRIGGER_TYPE and panic!'d on any unmapped string — a runtime crash on the first draft save for a trigger that forgot to map. Replace it with a required associated const DRAFT_KIND, so a missing mapping is a compile error. user_draft_item_kind() now just returns Self::DRAFT_KIND; every impl (OSS + EE) declares the const. Also fix the app deploy/delete draft cleanup to cover raw_app: raw apps deploy and delete through the same internal path, but the cleanup filtered typ = 'app' only, leaving raw_app drafts dangling (create_app_internal apps.rs:1465, update path apps.rs:2077) or un-archived on delete (apps.rs:1687). Co-Authored-By: Claude Fable 5 * fix(drafts): deleting an item wipes every user's draft, not just the caller's Scripts/flows/apps already wiped all users' drafts on delete, but resources/variables/schedules/triggers called delete_user_draft (caller-scoped), so a teammate's draft on the just-deleted item lived on forever — surfacing through fetch_other_drafts_users with no item left to deploy onto. Add delete_all_drafts_for_path (all emails + the legacy NULL row) and use it in every delete handler; keep delete_user_draft for the discard-my-own-draft flow where the item lives on. Co-Authored-By: Claude Fable 5 * perf(drafts): skip other-drafts query on non-editor reads (get_draft=false) maybe_overlay_draft ran fetch_other_drafts_users (a usr join) on every get-by-path, including worker/CLI reads of MB-scale flows & apps that pass get_draft=false and never render the draft overlay or "others editing" surfaces. Gate the query behind get_draft — only editor reads pay for it. Reset-to-deployed editor reloads still get it (they pass get_draft=true). (Eliminating the serde_json::to_value materialization of the deployed payload needs WithDraftOverlay to become generic over T, which is folded into the get-by-path choreography refactor.) Co-Authored-By: Claude Fable 5 * refactor(drafts): single-source the kind→table mapping via deployed_table() The kind→table dispatch lived in three places that could drift: the TriggerCrud string-match (already replaced by const DRAFT_KIND), the table_for_kind access-check map, and a hand-written draft_only CASE in list_drafts. Add UserDraftItemKind::deployed_table() as the single source (plus an ALL enumerator). table_for_kind now delegates to it, and the list_drafts draft_only CASE is generated from it at runtime (table names come from the closed enum, never user input — no injection). Drift between the access check and the existence check is now impossible by construction. Webhook and the native triggers (poll/cli/nextcloud/google/github) map to None: they have no path-keyed backing table and aren't draftable, so they report draft_only=true and use a path-only access check. This also fixes a latent bug where table_for_kind mapped native kinds to native_trigger, which has no `path` column — the access query `SELECT 1 FROM native_trigger WHERE path = $1` would have errored. Co-Authored-By: Claude Fable 5 * ee repo * fix(drafts): close variable draft-secret laundering oracle (sentinel + rehydrate) save_draft encrypts secret variable values with the workspace key, but the ciphertext was round-tripped to the client and the deploy endpoints decrypted whatever $encrypted: ciphertext the client submitted (variables.rs create/update). Any workspace member who can write a variable path could take an arbitrary workspace-key ciphertext (another user's secret draft via GET /drafts/get with only path-read, or a deployed secret's stored value) and submit it as their own secret variable's value — the server decrypted it and, since they own the path, they read the plaintext back. That bypasses the audited decrypt_secret permission. Fix: the ciphertext never leaves the server. get_variable swaps a draft secret's $encrypted: value for an opaque $draft_secret sentinel (both the draft overlay and the draft-only inner stand-in). On deploy the client sends the sentinel back and the server rehydrates the plaintext from the caller's OWN draft row — the only ciphertext it ever decrypts is one it encrypted for this exact (workspace, path, email). A raw $encrypted: submitted by a client is now rejected outright. Co-Authored-By: Claude Fable 5 * fix(drafts): don't clobber a secret draft when autosaving the $draft_secret sentinel After reload the client holds the $draft_secret sentinel for a secret variable (never the ciphertext). Editing some OTHER field (description, labels) triggers an autosave carrying value="$draft_secret" — and save_draft's encrypt_secret_variable_value, seeing a non-empty, non-$encrypted: string, encrypted the literal sentinel, overwriting the real ciphertext in the draft row and losing the secret. Treat the sentinel as "secret unchanged": restore the $encrypted: ciphertext already stored in this user's draft row instead of encrypting the placeholder (falling back to empty only if there's no prior ciphertext). The new lookup reuses the same query shape as the deploy- time rehydrate, so no new offline cache entry. Co-Authored-By: Claude Fable 5 * Revert "$draft_secret" sentinel approach for variable draft secrets Reverts 339c259fce and b2c38ef407. Instead of round-tripping a sentinel and rehydrating server-side, we close the laundering vector more simply by disabling cross-user draft visibility for triggers/resources/variables (next commit) — an attacker can no longer read another user's secret draft ciphertext to launder it. Co-Authored-By: Claude Fable 5 * feat(drafts): keep drafts private to their owner for resource/variable/trigger kinds Replaces the reverted $draft_secret sentinel: instead of laundering-proofing the ciphertext round-trip, simply don't expose other users' drafts for the drawer kinds (resource/variable/triggers). A viewer can no longer obtain another user's secret-variable draft ciphertext, so it can't be laundered into plaintext via deploy. UserDraftItemKind::shares_drafts_across_users() — true only for script/flow/app/raw_app. maybe_overlay_draft skips other_drafts_users for non-sharing kinds, and get_draft_for_user (View JSON / Fork) returns 404 for them. Own-draft load/save is unchanged. Co-Authored-By: Claude Fable 5 * refactor(drafts): make the list-page asterisk hint a shadow of UserDraftDbSyncer The optimistic `*` hint was written by three open-editor publishers, so draft deletions that didn't go through an editor (banner discard, autosave-back-to-baseline, Review & Deploy) left a stale asterisk that a server refetch couldn't clear (the hint overrides is_draft). Move ownership to the syncer — the one choke point where a draft's existence actually changes: - postSave sets the hint on a saved write (value !== null) and clears it on a delete (null), so every syncer-routed delete clears it for free. - save() lights it optimistically when a real save is scheduled, so the asterisk still tracks the editor's banner without the debounce lag. The editors no longer SET the hint; they only CLEAR it when settled at the deployed baseline (so a draft discarded from another tab disappears on reopen). discardDraft drops its explicit clear (postSave covers it); deployDraft keeps one (it deletes server-side, bypassing the syncer). Co-Authored-By: Claude Fable 5 * chore(migrations): fold draft index + secret scrub into the base sync migration Merge 20260610095349_draft_workspace_path_typ_index and 20260610100018_scrub_secret_variable_drafts into the base 20260528143710_draft_user_sync_schema migration (the index creation + secret-draft scrub in .up, the index drop in .down; the scrub stays irreversible). 20260609165313_remove_draft_only remains standalone. Verified the full chain applies and reverts cleanly on a fresh DB. (Rewrites an already-applied migration — existing dev DBs need a reset.) Co-Authored-By: Claude Fable 5 * refactor(drafts): promote the get-by-path draft choreography to one helper The "Some(deployed) → overlay / None+get_draft → draft-only / None → 404" dance was copy-pasted across the get-by-path handlers and had drifted (different 404 text, the trigger one missing the draft-only fallback at first). Promote it to windmill_common::overlay_or_draft_only, which takes the deployed entity as Option and a per-route not_found closure. Converts scripts, flows, apps, schedules, and triggers onto it. Resources keeps its own (it runs an async explain_resource_perm_error on the 404 path) and variables keeps its own (secret-decrypt logic interleaved with the draft fetch) — both genuinely diverge from the common shape. (The serde_json::to_value elimination via a generic WithDraftOverlay, and the list-only draft synthesis dedup, remain as follow-ups.) Co-Authored-By: Claude Fable 5 * perf(drafts): serialize the deployed overlay payload in one pass maybe_overlay_draft materialized the deployed entity into a serde_json::Value tree (serde_json::to_value) and then serialized that tree again into the response — two passes plus a full Value allocation over what can be an MB-scale flow or app, on every get-by-path (including get_draft=false worker/CLI reads). Hold WithDraftOverlay.inner as a boxed erased_serde::Serialize trait object instead, so the deployed payload flattens straight into the response in one pass. The struct stays non-generic, so the helper and all seven handler return types are unchanged; only the deployed type now needs Send + 'static (already true — they're owned rows; added 'static to TriggerCrud::Trigger to say so). Co-Authored-By: Claude Fable 5 * refactor(drafts): one helper for the draft-only list synthesis query The "draft rows at paths with no deployed counterpart" query was copy-pasted into the variable / resource / schedule / trigger list handlers, each hardcoding its own typ literal and NOT EXISTS table — a drift hazard. Promote it to windmill_common::fetch_draft_only_list_rows, which derives the absence-check table from kind.deployed_table() (the same single source as the access check and draft_only flag). Each handler keeps its own include_draft_only gating and per-type row mapping (genuinely entity-specific); only the shared SQL is deduped. The trigger handler's prior generated-SQL version is folded in too. Co-Authored-By: Claude Fable 5 * fix(drafts): route raw-app draft deploys through the raw-app endpoint [P1] deployDraft's raw-app guard was `kind === 'app' && rawApp`, but Review & Deploy passes `kind === 'raw_app'` (raw apps are their own DRAFT_KIND), so the guard never fired and the row fell into the visual-app branch. There `d.value` is undefined (a RawAppDraft has files/runnables/data, no `value`), so AppService.updateApp did a partial update — resetting policy to the publisher default, never bundling/deploying the files — the backend then deleted the user's raw_app draft rows, and the UI reported "deployed". The work-in-progress was destroyed without ever deploying. Route `kind === 'raw_app'` (or the editor's `app` + rawApp) through deployRawAppDraft. The now-unreachable `raw_app` arm of the visual-app branch is dropped. Co-Authored-By: Claude Fable 5 * fix(drafts): allow draft saves for item-level extra_perms writers [P1] require_can_write_path only accepted namespace rules (own u/, member g/, writable f/), dropping the item-level extra_perms check the old create_draft had. A user granted write on e.g. u/alice/script via the Share dialog could still deploy it (the update endpoints go through RLS) but could no longer save a draft — and because the editors autosave continuously with no permission gate, editing a shared item produced a persistent "Save failed: you don't have write permission" and Ctrl/Cmd+S failures. Add the item-level fallback: when a deployed row exists at the path, check its extra_perms for a write grant (every deployed table has extra_perms; the table comes from the closed deployed_table() mapping). Draft-only items have no row and stay governed by the namespace rules. Co-Authored-By: Claude Fable 5 * fix(drafts): pass rawApp on get-app for never-deployed raw-app drafts [P2] A raw app that has only ever been drafted has no `app` row, so get_app resolves the draft kind from the `rawApp` query param. getDraftDiffValues ("Show diff") and deployRawAppDraft both fetched with getDraft=true but without rawApp, so the backend looked up the visual-app draft kind, found nothing, and 404'd. Pass rawApp so the raw_app draft is found. Co-Authored-By: Claude Fable 5 * feat(drafts): surface the localStorage→DB migration with toasts migrateUserDraftsToDb already uploaded legacy "userdraft/..." entries and cleared them on success (and runs after the v1→userdraft normalizer). Add the user-facing surface: when real legacy entries are detected, show an info toast "Migrating local storage drafts ..."; on a per-draft failure show an error toast "Could not migrate draft in workspace " with a "Delete draft" action that drops the stuck localStorage entry (otherwise it retries every mount). Unparseable junk is still cleared silently up front, so the toast only fires for genuine drafts. Co-Authored-By: Claude Fable 5 * test(drafts): cover the autosave pipeline's pure-logic utilities [P2] The deleted draft tests left the new debouncer + coalescing runner — the core of the autosave pipeline — with zero coverage. Add vitest suites (16 cases) for debouncerByKey (debounce window, latest-task-wins, maxDebounceMs ceiling under a trickle, fresh-chain-after-fire, cancel, key independence) and coalescingRunner (immediate run when idle, coalesce burst to in-flight + latest, displaced-task drop, submitAndWait resolve/reject/displaced, cancel semantics, key independence). Broader replacement (save_draft conflict semantics + the require_can_* checks as backend integration tests) still outstanding. Co-Authored-By: Claude Fable 5 * feat(drafts): add UserDraft.seed — a one-shot baseline load that never POSTs The page editors bracket their new-draft / deployed-baseline loads with stopSync + restartSync so the programmatic write isn't synced as the user's edit. Forgetting restartSync silently disables autosave for the session — the footgun behind the three divergent resume strategies the review flagged. `UserDraft.seed(kind, path, value)` is the scoped alternative: it sets the cell (all reactive readers update) and arms a single-shot `seedNextWrite` flag the sync effect consumes — adopting the value as the new baseline and skipping exactly that one POST, with no suspension to resume. Additive: stopSync/restartSync are untouched and still used for the writes that fan out across editor components (initContent cascades). Foundation for converting the editor bootstraps off the bracket. Co-Authored-By: Claude Fable 5 * refactor(drafts): extract usePageDraftSync; convert the scripts editor onto it First step of unifying the four page editors' hand-rolled draft orchestration (three divergent handle-ownership models + an easy-to-forget recordRemoteSync). usePageDraftSync is the single model — the page analogue of useTriggerDraftSync — owning the re-keyed autosave handle, the live-editor-draft registry entry, recordRemoteSync (now a method, not a per-page ritual), seedBaseline (via UserDraft.seed), and draft removal. The scripts editor is converted as the reference adoption: its inline useReactive handle, live-editor-draft effect, recordRemoteSync, and the two UserDraft.remove calls now go through draftSync. The new-draft stopSync bracket stays (it spans ScriptBuilder's initContent cascade). Verified in a real browser against the dev stack: load fires no spurious save, a code edit triggers exactly one save_draft POST + a draft row, and the draft persists across reload. Flows / apps_raw / apps conversions follow. Co-Authored-By: Claude Fable 5 * refactor(drafts): convert the flows editor onto usePageDraftSync Replace the inline useReactive handle + UserDraftDbSyncer.recordRemoteSync + UserDraft.remove with draftSync. effectivePath is omitted — flows register their live-editor-draft entry through FlowBuilder (liveEditorDraftStoragePath), so the composable doesn't double-register. The new-draft stopSync + armRestartOnFirstInteraction bracket stays (it spans FlowBuilder's seed cascade). flowStore reads/writes draftSync.draft. Verified in a real browser: load fires no spurious save, a summary edit triggers exactly one save_draft POST + a draft row, and the edit persists across reload. Co-Authored-By: Claude Fable 5 * refactor(drafts): convert the apps_raw editor onto usePageDraftSync Replace the UserDraft.use handle + mirror, UserDraftDbSyncer.recordRemoteSync, and UserDraft.remove with draftSync. `path` is a mount-scoped plain `let` (the editor remounts per path), so the composable's useReactive re-keys only on workspace change — equivalent to the prior capture-once use(). effectivePath omitted (RawAppEditor owns the live-editor-draft entry); the new-draft stopSync + armRestartOnFirstInteraction bracket stays. Type-checked and behavior-equivalent (handle mechanism unchanged; the centralized recordRemoteSync/remove read the same `path`). Not browser-exercised here — no existing raw app in the dev workspace and the new-draft template-picker flow isn't scriptable quickly; scripts and flows (same composable) were verified live. Co-Authored-By: Claude Fable 5 * fix(drafts): remove app autosave at its canonical key after deploy/rename AppEditor keys the app autosave on the URL draft path and passes it down as userDraftPath, but AppEditorHeader's post-deploy cleanup re-derived the key from the just-typed deploy path (createApp) / the live $appPath (updateApp) instead. For a new app the autosave lives at u/{user}/draft_{uuid} while the typed path is the user's chosen name, and a rename leaves the autosave at the original key — so removing at path/$appPath missed the real draft row and orphaned it. Use the canonical userDraftPath AppEditor already provides. This is the "children re-derive the UserDraft key" fragility from the review, addressed without giving apps a page-level handle — apps deliberately lets AppEditor own the handle so the entry is destroyed on unmount (a page handle would keep it alive and reintroduce spurious autosaves on every /edit visit). Co-Authored-By: Claude Fable 5 * test(drafts): integration tests for save_draft conflict semantics + authz [P2] Replaces the deleted drafts.rs (which targeted the removed /drafts/create API) with tests for the new surface: - save_draft upsert → stale-last_sync conflict (rejected, value unchanged) → force overwrite → delete, the optimistic-concurrency contract. - require_can_write_path: own namespace allowed, another user's namespace rejected, operators rejected. - the item-level extra_perms fallback — a user granted write on a deployed item can save a draft on it (regression test for the authz drop). - cross-user draft privacy: GET /drafts/get is 404 for the drawer kinds (variable/resource/triggers), not blocked for script/flow/app. Co-Authored-By: Claude Fable 5 * chore(sqlx): refresh offline cache after the main merge The merge auto-combined both branches' additions inside the resource get-by-path query_as! (our draft_only/is_draft columns + main's folder_labels(...) inherited_labels), producing query text neither branch had cached — so the offline build failed for it. Regenerate the entry (rename to the new content hash) and refresh a re-described workspace query. Feature-gated/EE entries the local prepare can't compile are left as committed. Co-Authored-By: Claude Fable 5 * ee repo ref * chore(system_prompts): regenerate for draft_only/is_draft trigger schema fields The openapi.yaml trigger/schedule schemas gained draft_only + is_draft, but system_prompts/generate.py wasn't rerun, failing the freshness check. Co-Authored-By: Claude Fable 5 * refactor(drafts): defer save_draft write authz to RLS via a FOR UPDATE probe require_can_write_path re-implemented the item-level extra_perms write rule in Rust (SELECT extra_perms + get_perm_in_extra_perms_for_authed) — a third copy of rules whose canonical home is the RLS policies, and the exact lane that regressed once already. Replace it with an RLS write-probe: `SELECT 1 FROM {deployed_table} WHERE path/workspace ... FOR UPDATE` through UserDB. Postgres applies UPDATE policies to rows locked via FOR UPDATE, so a returned row means the canonical policies (see_own / see_member / folder-write / see_extra_perms_*_update / admin_policy) would let this user UPDATE the row — no write rule re-implemented, no drift possible. The probe's row lock is released by the immediate commit. The claim-based namespace checks stay, evaluated FIRST: they read the same JWT claims RLS does (so outcomes are identical), they spare the autosave hot path a DB round-trip for the common own-namespace case, and they are the entire check for draft-only paths — where no deployed row exists, so there is structurally nothing for RLS to evaluate. The u/own + folder-owner part now goes through the shared windmill_api_auth::require_owner_of_path instead of bespoke code. Adds a read-only-grant test case (extra_perms value false): the row is visible under the SELECT policy but FOR UPDATE filters it under the UPDATE policy — pinning the semantics the probe relies on. All 4 draft integration tests pass. Co-Authored-By: Claude Fable 5 * chore: point ee-repo-ref at the EE branch merge (has DRAFT_KIND consts) ee-repo-ref was set to main's EE commit (d45b9a6) while the EE branch was unpushed; building OSS (which requires const DRAFT_KIND on TriggerCrud) against that EE ref fails with E0046 on every EE trigger impl. The EE branch head e936e9a — the merge of d45b9a6 into the EE remove-workspace-drafts branch, carrying the DRAFT_KIND consts — is now pushed; point at it. Co-Authored-By: Claude Fable 5 * fix(drafts): ignore permissioned_as fields in the unsaved-changes comparison The schedule cfg carries permissioned_as / preserve_permissioned_as — run-as deploy directives, not user-edited draft content — and the editor round-trips them asymmetrically (preserve_… is rebuilt as !!cfg.permissioned_as on load but `|| undefined` on build), so the banner comparison could report a phantom diff. Extract the normalization into a shared normalizeDraftForCompare (JSON round-trip + a DRAFT_COMPARE_IGNORED_FIELDS list with the two fields) and use it from BOTH comparators: draftValuesEqual (variable/resource banner + discardIf) and useTriggerDraftSync's cfgDiffers (schedule and trigger banners, the persist-effect's at-baseline discard, restore) — one ignore-list, no way for the two to disagree. Co-Authored-By: Claude Fable 5 * nit * fix(drafts): at-baseline discard is auto-gated and only fires with a draft Two related fixes to useTriggerDraftSync's persist-effect: 1. The reactive at-baseline discard bypassed the "Enable auto-save" toggle: with autosave off, value saves were parked (correct) but the discard's value:null still POSTed — so the editor never wrote drafts yet kept reactively DELETING them, and the only network traffic was discards. Thread `auto` through UserDraft.discard to the syncer; the persist-effect passes auto:true (parked for Ctrl/Cmd+S when the toggle is off), explicit discards (banner button, post-deploy cleanup, reset-to-deployed) stay ungated. 2. The discard fired unconditionally whenever the form sat at the deployed baseline — including a spurious value:null POST on every drawer open. Guard on cfgDiffers(h.draft, deployed): undefined on a fresh open (nothing to discard) and equal to deployed right after a discard (no repeat per cfg recompute). Verified live as a non-admin user on a schedule: toggle on → no POST on open, edit → one value save, revert → one discard; toggle off → zero POSTs (everything parked), banner still functional. Co-Authored-By: Claude Fable 5 * feat(drafts): scope the "Enable auto-save" toggle to the page editors Add a canBeDisabled opt (default false) to UserDraft.use / useReactive / useMany specs, threaded through acquireEntry into the reactive mirror's save opts. The syncer's auto-save gate (and the pagehide-flush skip) now only applies to saves whose handle opted in: the four full-page editors — script / flow / raw app via usePageDraftSync, app via AppEditor's use() — which are exactly the surfaces whose AutosaveIndicator carries the toggle. Drawer editors (variables / resources / schedules / triggers) keep the default and always sync regardless of the toggle — previously a toggle flipped off in some browser silently disabled their autosave and the optimistic asterisk (both sit behind the same gate) with no toggle UI anywhere on those surfaces to explain it. Verified live: schedule edit with the toggle off now POSTs the value save (and the discard on revert); script editor with the toggle off still parks everything for Ctrl/Cmd+S. Co-Authored-By: Claude Fable 5 * fix(drafts): consume the import handoff stores in the new-draft bootstrap The /add pages used to read importStore / importFlowStore / importScriptStore / sessionStorage rawAppImport to seed the editor from "Import from YAML/JSON", "Build app" (from a script/flow), and the workflows-as-code import. Since /add became a pure redirect to /{kind}/edit/u/{user}/draft_{uuid}?new_draft=true, the writers kept firing but nothing consumed the payload — every import landed in an empty editor. Consume them (one-shot read + clear) in the four edit pages' new_draft branches, layering the imported content over the empty template with path kept '' so the friendly-name generation still runs: - scripts: $importScriptStore spread over the empty script (non-empty content also keeps ScriptBuilder's template bootstrap from overwriting it — that cascade is gated on content == ''). - flows: $importFlowStore spread over the empty flow. - apps: $importStore — wrapped exports ({summary, value, policy}) and bare App values, mirroring main's /add. - raw apps: $importStore then sessionStorage rawAppImport (the full page reload for cross-origin isolation drops in-memory stores); honored only when the payload carries files (rendering gates on them), skipping the framework picker; otherwise the template seed. Verified live: "Build app" from a script lands on /apps/edit with the canvas seeded from the script instead of an empty editor. Co-Authored-By: Claude Fable 5 * chore(drafts): remove dead delete_user_draft + its stale doc [C4] The doc claimed item delete handlers call it, but those all moved to delete_all_drafts_for_path (an item delete is for everyone); the caller-scoped discard goes through the save_draft route with value:null. That left delete_user_draft with zero callers (OSS and EE) — remove it and its orphaned sqlx cache entry, and reword the contrast note on delete_all_drafts_for_path. Co-Authored-By: Claude Fable 5 * chore(migrations): retire the sync_drafts-era index comment + right-size it [C6] The draft_user_sync_idx comment described the deleted sync_drafts polling endpoint (editors polling created_at ranges every 2-10s) — that design was replaced by recordRemoteSync + save_draft last_sync, and nothing range-scans draft.created_at anymore. Since this migration only exists on this branch, fix it before it ships: the index's real consumer is GET /drafts/list (workspace_id + email equality, ORDER BY path), so swap the vestigial trailing created_at for path (rows come back in output order) and rename to draft_user_listing_idx. Chain re-verified on a fresh DB. (Byte-for-byte migration edit — dev DBs that already applied it need a reset, as with the earlier consolidation.) Co-Authored-By: Claude Fable 5 * fix(drafts): discardDraft awaits the delete POST before refetching [I5] UserDraftDbSyncer.save resolves at enqueue time for debounced saves, so discardDraft's await finished ~1.5s before the value:null POST and the invalidateWorkspaceDrafts refetch re-listed the just-discarded draft. Use immediate: true (resolves after the POST lands), matching every sibling delete-then-refetch path. Co-Authored-By: Claude Fable 5 * fix(drafts): replace stale draft_only gates in the builders [I6] draft_only was dropped from the get-by-path wire shape (the column is gone; overlays carry no_deployed instead), so these four reads were always undefined: - ScriptBuilder "Exit & See details" gate and TriggersEditor's isDeployed treated every draft-only script as deployed → now keyed on savedScript.no_deployed like the sibling reads right next to them. - FlowBuilder's deploy path never took the direct-save branch for draft-only flows (no deployed version exists to compare against), and "Exit & see details" was offered for draft-only flows (404 details page) → both now keyed on the newFlow prop (driven by no_deployed), which the rest of the file already uses. Co-Authored-By: Claude Fable 5 * fix(drafts): Ctrl/Cmd+S flushes the draft in the low-code app editor [I7] The app editor's keydown handler swallowed the shortcut with a bare preventDefault() — every other page editor flushes the pending autosave (UserDraftDbSyncer.flush) so the AutosaveIndicator narrates Saving... → Saved and parked edits (autosave toggle off) actually persist. Wire the same flush, skipped in the AI session pane where no UserDraft handle exists. Co-Authored-By: Claude Fable 5 * fix(drafts): AI tool strings no longer describe drafts as localStorage [C2] The copilot tool results/messages still told the model drafts were "saved to local storage" / "a browser-only local draft" — drafts are per-user rows in the server-side draft table now. Misleading the model about the storage medium produces wrong explanations to users (e.g. "your draft will be lost if you clear your browser data"). Reword all occurrences to "draft" / "per-user draft (saved server-side)". Co-Authored-By: Claude Fable 5 * fix(openapi): drop stale draft_only request props, fix OtherDraftUser, regen deref [D4][C5] - The create-script (NewScript), createFlow, createApp and createAppRaw request bodies still documented draft_only — the backend request structs no longer read it, so an older CLI sending draft_only: true is silently ignored and fully deploys. Remove the property from the spec so generated clients can't offer it. (Response-side draft_only on the Listable* rows stays — the list synthesis populates it.) - UserDraftOverlay.other_drafts_users item schema declared email and a required draft_saved_at; OtherDraftUser serializes only username (nullable for the legacy row — emails never leave the server). Align the schema. [C5] - Regenerate openapi-deref.yaml/.json (served at runtime via include_str!) — they still advertised getScriptByPathWithDraft and the deleted draft surface, and now carry the drafts/save_draft routes. Frontend gen client regenerated; check:fast clean. Co-Authored-By: Claude Fable 5 * fix(sessions): stop session pane from clobbering server-side raw-app drafts [P1] loadRawApp seeded the session runtime from result.value (the deployed payload), ignoring the .draft pocket returned by the get-by-path overlay. The subsequent UserDraft.save then POSTed deployed content with no last_sync recorded, silently overwriting the user's server draft. Now the no-draft branch consumes result.draft when present (matching the flow/script branches) and records draft_saved_at via recordRemoteSync so later session saves are conflict-checked instead of treated as fresh. Also corrects the header and aiDraft-branch comments that claimed the overlay merges drafts into top-level fields — it never does. Co-Authored-By: Claude Opus 4.8 * fix(rust-client): pass new get_draft arg to variable_api::get_variable getVariable gained a GetDraft query parameter (per-user draft overlay), so the generated client fn takes a sixth argument. Verified with the same generate+check pipeline CI runs (rust-client/dev.nu --check). Co-Authored-By: Claude Opus 4.8 * nit: Workspace fork mention * fix(drafts): don't leak other_drafts_users on draft-only private kinds [P2] fetch_draft_only built the other_drafts_users list unconditionally, while the deployed-overlay path gates it on shares_drafts_across_users. For the drawer kinds (resource/variable/triggers) drafts are private to their owner, so a draft-only GET was the one route that still told a viewer who else has a draft at the path. Apply the same kind gate. Co-Authored-By: Claude Opus 4.8 * perf(drafts): probe a single row in the RLS write-probe [P2] The script table keeps one row per version at the same path, so the FOR UPDATE probe locked the entire version history and serialized against concurrent deploys. LIMIT 1 locks one row — any UPDATE-policy visible row proves writability (same pattern as scripts.rs's latest-version lock). Co-Authored-By: Claude Opus 4.8 * fix(drafts): consume the /add?param= seeding intents in new_draft branches [D2] The /add routes' redirect preserves query params, but the edit pages' new_draft branches only consumed the YAML/JSON import stores — every other intent the old /add pages handled landed in a blank editor: - scripts: ?hub= and ?template= forks (with locked language and a `_fork` path suggestion), ?wac=python|typescript (WAC editor template + language), ?lang=, ?initial_args= (URL form), and the base64-JSON #hash payload (run page "Fork", workspace_settings handler-template buttons; WAC detection restored for imports too) - flows: ?hub= (preprocessor placeholder replacement + env-variables panel), ?template=/?template_id=, ?fork=true (fork_flow localStorage / window.opener handoff), #state, ?tutorial= - apps: ?hub= (fromHub inputs panel), ?template=/?template_id=, ?tutorial= The redirect itself also dropped the URL hash — SvelteKit forbids url.hash in load, so it forwards window.location.hash (correct for all hash producers: they arrive as full page loads via window.open / target=_blank). Seeding priority and toasts mirror main's /add pages. Verified live: hub/template/wac/hash/fork intents for scripts and flows, hub for apps (dev hub returns empty payloads, code path confirmed via toast + inputs panel); no autosave POSTs fire during seeding. Co-Authored-By: Claude Opus 4.8 * fix(drafts): LS→DB migration no longer clobbers fresher server drafts [P2] The one-off localStorage migration POSTed every entry with force: true, unconditionally overwriting whatever the user had since saved server-side from another browser. It now passes the LS copy's lastWrittenAt as last_sync (epoch 0 when absent), so the server's conflict rule arbitrates: empty slot → insert; server draft fresher → conflict, LS copy dropped; LS copy fresher → upload wins. Verified all three outcomes against the live save_draft endpoint. Co-Authored-By: Claude Opus 4.8 * refactor(raw_apps): drop banned $bindable(default) on template picker open [P2] `open = $bindable(false)` on an optional prop is the AGENTS.md-banned pattern (the default masks the undefined state). The only caller always binds a boolean, so `open` is now a required prop with a plain `$bindable()`. Co-Authored-By: Claude Opus 4.8 * fix(drafts): fork others' drafts via the import handoff, not an eager save The Fork actions (OtherUsersDraftsModal + DraftBadge popover) saved the fetched draft server-side immediately and navigated to the fork path, which surfaced three problems: a server draft existed before the user edited anything, the Path widget treated the slot as an existing item ("Only the owner can change the path"), and the value's draft_path kept the source path while the URL said X_owner_fork. Forking now routes through the same one-shot import handoff as the "Import from YAML/JSON" actions (new shared forkDraftToImport helper): stash the value in the kind's import store, navigate to /add, and let the new_draft branch seed a brand-new own item — nothing saved until the first real edit, fresh renamable path, no source identity riding along. The editPathFor/currentUserUsername plumbing that only served the old flow is removed from both fork surfaces and their callers. The new_draft branches also clear the previous path's draft-presence state (otherDraftsUsers, loadedFromDraft, stale-draft timestamps) — the page component is reused across same-route navigation, so forking from an editor with collaborators used to carry the "Others are working on this" hint onto the fresh draft. Verified live: fork of a legacy draft seeds content+summary on a fresh u/{user}/draft_{uuid} slot with zero save_draft requests and no leftover collaborator hints. Co-Authored-By: Claude Opus 4.8 * refactor(drafts): replace deprecated Popover with meltComponents Popover - Migrate from old Popover.svelte to meltComponents/Popover.svelte - Convert to new trigger/content snippet pattern with openOnHover=true - Maintain hover behavior with debounceDelay=100 - Add key to visibleUsers each block for Svelte 5 compliance * feat(drafts): seed forked drafts with the source path in the forker's namespace Forking u/admin/myflow as guest now seeds the Path widget with u/guest/myflow instead of a random friendly name — everything after the source path's first two segments is kept, so f/folder/my/flow becomes u/guest/my/flow. The re-homed path travels from forkDraftToImport to the new_draft branches as a ?seed_path= param (the redirect preserves query params; plain ?path= would be eaten in transit by ScriptBuilder's legacy collab-param cleanup, which deletes path/collab from the live searchParams object). The script editor also passes initialPathChosen for any seeded path — MetadataGen fires onChange for a non-empty summary at mount, and the summary→path auto-slug would otherwise overwrite the explicit seed (hub/template forks and URL-hash payloads included). Verified live: forking a draft on u/admin/hard_working_script seeds path u/admin/hard_working_script (with the "path already used" warning), keeps the drafted summary/content, and still fires no save_draft until the first edit. Co-Authored-By: Claude Opus 4.8 * fix(drafts): DiffDrawer "Restore deployed" actually discards the draft [P1] All four restoreDeployed implementations POSTed the delete through the debounced pipeline and reloaded with getDraft defaulting to true: the reload's draft write re-entered the autosave mirror (the one-shot seed guard was consumed on first load), and debouncerByKey displaced the queued value:null with the new save — the delete never reached the server and the editor re-rendered the draft it was told to discard. They now funnel through runResetToDeployed (the stopSync-bracketed delete the AutosaveIndicator reset already uses) with each page's proven reset body (getDraft: false reload), so the suspension mutes the mirror while the delete flushes and sync re-arms on first interaction. Also fixes the raw-app drawer navigating to the visual app editor (/apps/edit) instead of /apps_raw/edit [P2]. Verified live on the script editor: Restore deployed issues exactly one save_draft ({value:null} answered status=saved), the server row is gone, and the editor re-renders the deployed content. Co-Authored-By: Claude Opus 4.8 * fix(drafts): deploying a draft-only item reliably deletes its draft Two bugs left the slot draft (u/{user}/draft_{uuid}) alive after a successful deploy: - RawAppEditorHeader.createApp removed the draft at the just-typed deploy path instead of the URL slot key (the visual header documents exactly this trap), orphaning the real row for every draft-only raw-app deploy. - Everywhere else the delete went through bare UserDraft.remove, which only QUEUES the value:null in the per-key debouncer. Editors that stay mounted through the post-deploy navigation (AppEditor, RawAppEditor — and timing-dependently the script/flow builders' post-deploy draft_triggers mirror) keep mirroring their working value, and one such write displaces the queued delete with a fresh save — observed live: deploying a new visual app re-saved the full grid value at the slot right after deploy. New discardDraftAfterDeploy helper (userDraftToast.ts) applies the same bracket runResetToDeployed uses: stopSync to mute the mirror, remove + immediate flush so the displacement window closes, re-arm on first interaction. Wired into the script/flow pages' onDeploy and both app headers' create/update paths (session-pane guards preserved). Verified live for all three kinds: draft-only deploy issues the value:null (status saved), the slot row is gone, and no post-deploy save re-creates it. Co-Authored-By: Claude Opus 4.8 * fix(drafts): forks-compare deploy clears drawer-kind drafts too The script/flow/app deploy endpoints delete the deployer's draft server-side, but the drawer kinds' (variable / resource / schedule / triggers) create/update endpoints never touch the draft table — their editors discard client-side after a save. deployDraft replayed the save but not the discard, so "Deploy n drafts" on /forks/compare deployed those kinds correctly and left the drafts listed forever. deployDraft now issues the canonical value:null delete (immediate) for the drawer kinds after a successful save. Verified live: deploying a draft-only variable from /forks/compare creates the variable and the draft row is gone. Co-Authored-By: Claude Opus 4.8 * fix(drafts): StaleDraftModal "Load latest deploy" actually discards the stale draft [P2] The modal invoked onLoadLatestDeploy directly — the draft = undefined write queued the delete and the reload's deployed-payload write displaced it, overwriting the stale draft with a deployed-identical copy (is_draft stuck on, asterisk persists, modal can't re-fire since draft_saved_at moved past the deploy). All four pages now run the callback through runResetToDeployed, same as the DiffDrawer restore. Verified live: stale-draft scenario → Load latest deploy → exactly one value:null POST, draft row gone, editor renders the newer deploy. Co-Authored-By: Claude Opus 4.8 * fix(drafts): don't acquire a sync entry for empty-path specs [P2] The read-only historical-hash view (/scripts/edit/x?hash=...) computes draftPath '' but useMany still acquired a live entry at ws/script/ — every edit mirror-POSTed to /drafts/save_draft/script/ (unroutable), populating the failures map and pinning the AutosaveIndicator on "Save failed" with a retry per debounce window. Empty-path specs now get a detached local-only handle: bind: works, nothing syncs — which is what usePageDraftSync's doc always claimed. Verified live: editing in the hash view fires zero save_draft requests. Co-Authored-By: Claude Opus 4.8 * fix(drafts): no spurious conflict after bfcache restore of a flushed page [P2] flushOnPageHide advances the server rows with unreadable keepalive POSTs and leaves lastSyncMap stale — correct when the document dies, wrong when bfcache resurrects it: the next autosave carried the pre-flush last_sync and the server rejected the user's own write as a conflict, opening DraftSyncConflictModal. The flushed keys are now remembered and dropped from lastSyncMap on pageshow with event.persisted, so the first post-restore save takes first-push semantics against this document's own flush. Co-Authored-By: Claude Opus 4.8 * ui(drafts): draft asterisk sits on the trigger row's main title The draft hint rendered at the end of the secondary path line (u/admin/item*) on the http/websocket/nats/kafka/email trigger lists — easy to miss. It now renders at the end of the row's bold title, and on the azure/gcp lists it moves from mid-title (after the path, before the topic suffix) to the end of the line. mqtt/postgres/sqs/schedules already had it on the title. Verified visually on the HTTP routes list. Co-Authored-By: Claude Opus 4.8 * fix(drafts): trigger editors save the FIRST edit, not the second Three interlocking fixes in the trigger autosave path: - The entry's one-shot first-write seed guard (skipNextWrite) was never consumed for trigger entries: the drawers don't write the cell on open (the form holds the state, unlike variables/resources which pass a defaultValue), so the guard stayed armed and silently swallowed the user's FIRST edit — banner on, no asterisk, no save until a second change. maybeRestore now seeds the cell with the post-load baseline (server draft overlay if any, deployed otherwise) via UserDraft.seed, consuming the guard without POSTing. - Guard hygiene in the cell's sync effect: a programmatic write consumes BOTH one-shot guards, and a no-op write (same serialization — e.g. the trigger pages fire openEdit twice per row click, re-seeding the same value) defuses a lingering seedNextWrite instead of leaving it armed to eat the next real edit. - The at-baseline auto-discard is now deferred + revalidated (600ms): with the cell seeded, the double-openEdit churn transiently shows form-at-deployed + cell-holds-draft and an immediate discard deleted the server draft on open; the recheck skips the transient state while a genuine user revert still discards. Verified live on the HTTP route editor: open-with-draft restores the draft with zero POSTs, the very first field edit saves, and reverting the form to the deployed value deletes the server draft. Co-Authored-By: Claude Opus 4.8 * ui(drafts): underscore-separated uuids in draft slot paths u/{user}/draft_{uuid} now uses underscores instead of dashes in the uuid — path segments elsewhere in Windmill are [a-zA-Z0-9_] words and downstream consumers treat '-' as a foreign character. Nothing parses the uuid back, so existing dashed slots stay valid. Co-Authored-By: Claude Opus 4.8 * fix(drafts): cascade draft cleanup on bulk-delete and rename Drafts have no SQL FK to their underlying items (only to password.email), so deletion and rename must cascade programmatically. Two gaps remained: - Bulk delete of variables/resources did not wipe per-user drafts at the deleted paths (single delete already did via delete_all_drafts_for_path). Cascade them — including the linked resource/variable rows the bulk delete fans into — so no orphaned draft-only rows survive. - Renaming a variable/resource/trigger left the per-user draft stranded at the old path. Add delete_own_draft_for_path and clear the deployer's own (+ legacy NULL) draft at the old path on rename, mirroring the script/flow/app rename path; teammates keep theirs (StaleDraftModal). Variable/resource renames also move the linked counterpart, so both kinds' drafts at the old path are cleared. Schedules have no rename path. Note: sqlx offline cache not yet regenerated for the new/changed queries. Co-Authored-By: Claude Opus 4.8 * fix(drafts): surface legacy NULL-email drafts and migrate pathless /add keys Legacy workspace-scoped drafts (pre-per-user rows + the remove_draft_only migration, all email IS NULL) stopped showing up because every per-user lookup matched only email = self. Match (email = self OR email IS NULL) everywhere a draft is surfaced or opened, with the owned row taking precedence (DISTINCT ON / ORDER BY email NULLS LAST): the home drafts list, the script/flow/app/drawer draft-only list syntheses, and the get-by-path overlay/fallback. The localStorage->DB migration also dropped pathless legacy keys (userdraft/w/{ws}/{kind}/ with no path) — the new-item /add autosave — because parseKey rejected an empty path, leaving them stranded in LS. Mint a fresh u/{user}/draft_{uuid} slot for those (same convention as the editors' /add redirects) so they migrate as regular draft-only items. Note: sqlx offline cache not yet regenerated for the changed macros. Co-Authored-By: Claude Opus 4.8 * chore(sqlx): regenerate offline cache for draft cascade + legacy-draft queries Adds the offline entries for the queries changed in the two preceding draft fixes (delete_own_draft_for_path, the maybe_overlay_draft/fetch_draft_only NULL-email fallback, and the script/flow/app draft-only syntheses). Also forwards the `http_trigger` feature from windmill-api-openapi to windmill-store: that crate imports `try_get_resource_from_db_as` unconditionally, but the fn is cfg-gated behind a trigger feature, so the openapi targets failed to compile in isolation (e.g. `--all-targets` under resolver 2) — which blocked `cargo sqlx prepare`. The feature was already present transitively in whole-workspace builds; this just makes it explicit where the symbol is used. Co-Authored-By: Claude Opus 4.8 * fix(drafts): resolve own draft owner in the admins workspace The draft-owner surfaces (home-page badge, "others' drafts", View JSON / Fork) resolve a draft's email to a username via the `usr` table. The `admins` workspace has no `usr` rows — there a user's "username" IS their email — so the join missed every owner and returned NULL, which the badge renders as "Legacy workspace draft". A user editing a deployed item in `admins` thus saw their OWN draft plus the genuine legacy NULL-email row both labelled "Legacy workspace draft" (the reported duplicate). Add the identity fallback `COALESCE(u.username, CASE WHEN workspace_id = 'admins' THEN email END)` to the script/flow/app draft_users aggregations and fetch_other_drafts_users, and accept username==email in get_draft_for_user. The genuine legacy row keeps username NULL (its email is NULL, so the CASE yields NULL too), so it alone reads "Legacy workspace draft" while the user's own draft now reads " (you)". Co-Authored-By: Claude Opus 4.8 * fix(drafts): keep "See others' drafts" after reset-to-deployed other_drafts_users is only computed by the backend when getDraft is true (the cross-user lookup is skipped otherwise). Reset-to-deployed reloads with getDraft:false, so the editors were overwriting the known list with the empty response — hiding the "See others' drafts" button until a full page reload recomputed it. Discarding one's own draft is independent of other users' drafts, which are untouched on the backend. Only assign otherDraftsUsers on a getDraft:true load. Applied to the script, flow, app and raw-app editors. Co-Authored-By: Claude Opus 4.8 * disable fork for operators * Path reactivity issue * docs(drafts): tighten draft-feature comments and drop dead code The draft feature accumulated many multi-paragraph comments that risked code-comment drift. Compact them to the AGENTS.md bar (constraints not narration, state-once, no drafting-history), de-duplicating the repeated draft_users / cascade / draft_only-synthesis rationale to one canonical version per theme with terse cross-references elsewhere (~1300 fewer lines). Also fixes three stale/contradictory comments surfaced while trimming: - the operator authz note claimed operators are "excluded from every draft surface", contradicting require_can_read_path (they can read some drafts, never write) — reworded to match the code; - a migration comment named a non-existent index (draft_user_sync_idx); - a syncer comment documented the wrong map-key separator. Removes notifyDraftLoaded (orphaned exported helper, no callers). Co-Authored-By: Claude Opus 4.8 * refactor(drafts): rename save_draft route to /update for CRUD consistency The draft write route was POST /drafts/save_draft/{kind}/{path}, which stutters with the /drafts prefix and uses a non-house verb. Rename it to POST /drafts/update/{kind}/{path} (operationId saveDraft -> updateDraft) to match the codebase's CRUD convention (/list, /get/{path}, /update/{path}). /list and /get/{kind}/{path} already matched and are unchanged. Updates the handler, openapi spec + dereferenced bundles, the two DraftService callers, the hand-built keepalive page-unload URL (it bypasses the generated client, so it wouldn't be caught by regeneration), and the integration tests. Response status values ("saved"/"conflict") are unchanged, so there is no behavior change. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: windmill-internal-app[bot] Co-authored-by: Claude Opus 4.7 --- ...57336378f9e85baf14d1dc34ed7e9b42e5e72.json | 16 + ...6f48e4330af7fbe61426e999c6073f630d88f.json | 41 + ...71052c112e6bbb3cf834f16176cbb7e1ac319.json | 4 +- ...d82c85129e2407819477adf7e87f097276ae2.json | 16 + ...ef6feafc448e70c30ba7e7c835f5e3167f3a5.json | 64 + ...885dac4de6171463ac0d1c45909da9f91b3a4.json | 59 + ...ff37dcc4c34f800ff4f385ec9f60933f83efe.json | 15 + ...1b027c022fc034dc378a071c046dc64571ac6.json | 15 + ...1f924bd4483c8f70095c05720bc4eb2d8df81.json | 16 + ...324d753cb2b1fac69ceb738df4c3d2a6270a5.json | 35 + ...d8e9353bc083724fa5e4530fd715fb237dc0c.json | 57 + ...156a66727e7a469abad1664ee03a3da31078a.json | 35 + ...b26e3ef9171f478e7ab3bd68eb09663b393c4.json | 35 + ...6e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json | 49 + ...bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json | 57 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...41a82b26105c1bf7833c6e5d6178e65974067.json | 125 + ...e07f0965efb65c51580c529e61d09b8742dfd.json | 99 + ...5fcfb1fdb296ea8893312d7d72ac80e4f506e.json | 16 + ...b0128c02bc44461ccc9cd9fb29de567762f63.json | 41 + ...4c4b467ff43d84618a3471eb7b1cfec13883d.json | 24 + ...88006066c83a2697913e43dc3c3db4bd4bd2e.json | 12 + ...23823945e84997b038d5b7915406c4ccb302d.json | 15 + ...cf4386eb97e4e00c7431ff2860b225d212780.json | 16 + ...da55f57b4de71db778642ed72654bf47d26a5.json | 20 + ...c749524f1db10a51f3de6cadd4826e6c1d422.json | 48 + ...4f0707c2243f085bc3fc442065fb4391330df.json | 16 + ...c2aa44d714bcc95e242ef329f6fd71c345c1.json} | 16 +- ...6e78437a5e4d3b5e2341ec5f7725a28870270.json | 2 +- ...4267d67f58f9cbff0d164b4df45de885b0e9d.json | 63 + ...df39178f5111119d50f2975dd1180502c0c52.json | 35 + ...89c59bd6c6f7bce52aa3707e923b32d9be3e2.json | 27 + ...cde48c5517ab36acd543a1f0a6119c34ce453.json | 16 + ...ea03e4a1d1edb4470812cd7dd57c657fa287a.json | 28 + ...f330cd3c8ef1ca692147210a36e56946f7ef6.json | 15 + ...5e59e5edda1faea72f39603814afb6a3cd596.json | 63 + ...86f0ecfcf63daa0b185994a410eb2fe41fad9.json | 23 + ...b7e3239773fa3e6352aef7e98467ad07f337d.json | 27 + ...5057b1c974a9a947bda133c155503dac1f545.json | 60 + ...acd868f9101250b0599fc66798a7852c7cdf5.json | 26 + ...e6697e20385b9a1f222382d7bf9e540b0b9aa.json | 63 + ...d86af3e3f0b9ab326e4ea89267514ecc66f71.json | 22 + ...f271cc64e5a3c9901c2e2f806ab3acbb4aa75.json | 57 + ...4cbece8a8206a678271218c4d486a65818745.json | 16 + ...49dd75b5f8ed859e410d2322ec3f565382fa8.json | 35 + ...c6536f9e2404002ba36046691aab955caf300.json | 22 + backend/Cargo.lock | 1 + backend/ee-repo-ref.txt | 2 +- ...0528143710_draft_user_sync_schema.down.sql | 25 + ...260528143710_draft_user_sync_schema.up.sql | 82 + .../20260609165313_remove_draft_only.down.sql | 5 + .../20260609165313_remove_draft_only.up.sql | 99 + backend/tests/ci_tests.rs | 2 +- backend/tests/dependency_map.rs | 3 +- backend/tests/relock_skip.rs | 2 +- backend/tests/script_auto_kind_failure.rs | 2 +- backend/tests/trigger_listener_queries.rs | 2 +- backend/tests/worker.rs | 2 +- backend/tests/workspace_export.rs | 2 +- backend/windmill-api-flows/src/flows.rs | 264 +- .../tests/apps.rs | 6 - .../tests/drafts.rs | 358 +- .../tests/flows.rs | 6 - .../tests/scripts.rs | 6 - .../tests/workspace_comparison.rs | 4 +- backend/windmill-api-openapi/Cargo.toml | 7 +- backend/windmill-api-schedule/src/lib.rs | 137 +- backend/windmill-api-scripts/src/scripts.rs | 375 +- .../windmill-api-workspaces/src/workspaces.rs | 71 +- .../src/workspaces_extra.rs | 4 +- backend/windmill-api/openapi-deref.json | 1059 ++- backend/windmill-api/openapi-deref.yaml | 7648 ++++++++++------- backend/windmill-api/openapi.yaml | 594 +- backend/windmill-api/src/apps.rs | 278 +- backend/windmill-api/src/drafts.rs | 563 +- backend/windmill-api/src/lib.rs | 4 +- backend/windmill-api/src/mcp/utils.rs | 4 +- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/path_autocomplete.rs | 4 +- backend/windmill-api/src/users.rs | 4 +- backend/windmill-api/src/utils.rs | 1 - backend/windmill-api/src/workspaces_export.rs | 29 +- backend/windmill-common/Cargo.toml | 1 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/scripts.rs | 6 +- backend/windmill-common/src/user_drafts.rs | 536 ++ backend/windmill-common/src/variables.rs | 13 + backend/windmill-store/src/resources.rs | 188 +- backend/windmill-store/src/variables.rs | 191 +- backend/windmill-test-utils/src/lib.rs | 4 +- .../windmill-trigger-azure/src/handler_oss.rs | 1 + .../windmill-trigger-email/src/handler_oss.rs | 1 + .../windmill-trigger-gcp/src/handler_oss.rs | 1 + backend/windmill-trigger-http/src/handler.rs | 1 + .../windmill-trigger-kafka/src/handler_oss.rs | 1 + backend/windmill-trigger-mqtt/src/handler.rs | 1 + .../windmill-trigger-nats/src/handler_oss.rs | 1 + .../windmill-trigger-postgres/src/handler.rs | 1 + .../windmill-trigger-sqs/src/handler_oss.rs | 1 + .../windmill-trigger-websocket/src/handler.rs | 1 + backend/windmill-trigger/src/handler.rs | 143 +- backend/windmill-trigger/src/types.rs | 23 + backend/windmill-types/src/flows.rs | 21 +- backend/windmill-types/src/lib.rs | 2 + backend/windmill-types/src/s3.rs | 23 +- backend/windmill-types/src/scripts.rs | 28 +- backend/windmill-types/src/user_drafts.rs | 18 + cli/src/guidance/skills.gen.ts | 216 + frontend/src/lib/coalescingRunner.svelte.ts | 118 + frontend/src/lib/coalescingRunner.test.ts | 130 + .../lib/components/AutosaveIndicator.svelte | 339 + .../src/lib/components/CompareDrafts.svelte | 112 +- .../lib/components/ContentSearchInner.svelte | 6 +- frontend/src/lib/components/DiffDrawer.svelte | 2 +- frontend/src/lib/components/DraftBadge.svelte | 291 +- frontend/src/lib/components/Editor.svelte | 63 +- .../lib/components/EncryptedDraftField.svelte | 48 + .../src/lib/components/FlowBuilder.svelte | 260 +- .../lib/components/LocalDraftBanner.svelte | 37 +- frontend/src/lib/components/Path.svelte | 19 + .../src/lib/components/ResourceEditor.svelte | 148 +- .../src/lib/components/ScriptBuilder.svelte | 312 +- .../src/lib/components/SimpleEditor.svelte | 6 + .../src/lib/components/VariableEditor.svelte | 143 +- .../src/lib/components/VariableForm.svelte | 132 +- .../components/WorkspaceDraftsBanner.svelte | 2 +- .../components/apps/editor/AppEditor.svelte | 87 +- .../apps/editor/AppEditorHeader.svelte | 360 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 24 +- .../AppEditorHeaderDeployInitialDraft.svelte | 55 - .../apps/editor/AppEditorTutorial.svelte | 2 +- .../apps/editor/AppJsonEditor.svelte | 100 +- frontend/src/lib/components/apps/types.ts | 31 +- .../DraftEditorModals.svelte | 102 + .../DraftSyncConflictModal.svelte | 92 + .../LocalDraftStaleModal.svelte | 125 - .../OtherUsersDraftsModal.svelte | 169 + .../confirmationModal/StaleDraftModal.svelte | 86 + .../UnsavedConfirmationModal.svelte | 36 - .../lib/components/common/modal/Modal2.svelte | 30 +- .../lib/components/common/table/AppRow.svelte | 78 +- .../components/common/table/FlowRow.svelte | 69 +- .../components/common/table/ScriptRow.svelte | 65 +- .../copilot/chat/global/core.test.ts | 175 +- .../components/copilot/chat/global/core.ts | 174 +- .../copilot/chat/global/userDraftAdapter.ts | 16 +- frontend/src/lib/components/diff_drawer.ts | 42 +- frontend/src/lib/components/flow_builder.ts | 27 +- .../components/flows/CreateActionsApp.svelte | 8 +- .../components/flows/CreateActionsFlow.svelte | 12 +- .../flows/content/FlowEditorDrawer.svelte | 21 +- .../src/lib/components/forkDraftToImport.ts | 60 + .../src/lib/components/home/ItemsList.svelte | 7 +- .../src/lib/components/home/treeViewUtils.ts | 1 - .../components/raw_apps/RawAppEditor.svelte | 70 +- .../raw_apps/RawAppEditorHeader.svelte | 354 +- .../raw_apps/RawAppTemplatePicker.svelte | 399 + frontend/src/lib/components/script_builder.ts | 37 +- .../scripts/CreateActionsScript.svelte | 2 +- .../search/GlobalSearchModal.svelte | 1 - .../components/sessions/FlowEditorView.svelte | 5 - .../sessions/RawAppEditorView.svelte | 5 - .../sessions/ScriptEditorView.svelte | 55 +- .../sessions/sessionRuntime.svelte.ts | 111 +- .../azure/AzureTriggerEditorInner.svelte | 29 +- .../email/EmailTriggerEditorInner.svelte | 46 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 44 +- .../triggers/http/RouteEditorInner.svelte | 38 +- .../kafka/KafkaTriggerEditorInner.svelte | 35 +- .../mqtt/MqttTriggerEditorInner.svelte | 36 +- .../nats/NatsTriggerEditorInner.svelte | 49 +- .../PostgresTriggerEditorInner.svelte | 64 +- .../schedules/ScheduleEditorInner.svelte | 64 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 43 +- .../triggers/useTriggerDraftSync.svelte.ts | 98 +- frontend/src/lib/components/triggers/utils.ts | 67 +- .../WebsocketTriggerEditorInner.svelte | 35 +- .../lib/components/usePageDraftSync.svelte.ts | 105 + frontend/src/lib/debouncerByKey.svelte.ts | 88 + frontend/src/lib/debouncerByKey.test.ts | 87 + frontend/src/lib/draftAddRedirect.ts | 32 + frontend/src/lib/encryptedDraft.ts | 10 + frontend/src/lib/localDraftHints.svelte.ts | 41 + frontend/src/lib/rawAppDeploy.ts | 4 +- frontend/src/lib/svelte5Utils.svelte.ts | 34 +- frontend/src/lib/tutorials/config.ts | 8 +- frontend/src/lib/userDraft.svelte.ts | 946 +- frontend/src/lib/userDraft.test.ts | 1145 --- frontend/src/lib/userDraftDbMigration.ts | 220 + frontend/src/lib/userDraftDbSyncer.svelte.ts | 475 + frontend/src/lib/userDraftToast.ts | 108 +- frontend/src/lib/userNamespace.ts | 31 + frontend/src/lib/utils/editInFork.ts | 6 +- frontend/src/lib/utils_draft_deploy.ts | 355 +- frontend/src/lib/workspaceDrafts.svelte.ts | 82 +- .../src/routes/(root)/(logged)/+layout.svelte | 35 +- .../routes/(root)/(logged)/apps/add/+page.js | 5 - .../(root)/(logged)/apps/add/+page.svelte | 160 +- .../routes/(root)/(logged)/apps/add/+page.ts | 6 + .../(logged)/apps/edit/[...path]/+page.svelte | 508 +- .../(logged)/apps/get/[...path]/+page.svelte | 2 +- .../(root)/(logged)/apps_raw/add/+page.js | 5 - .../(root)/(logged)/apps_raw/add/+page.svelte | 693 +- .../(root)/(logged)/apps_raw/add/+page.ts | 6 + .../apps_raw/edit/[...path]/+page.svelte | 539 +- .../apps_raw/get/[...path]/+page.svelte | 2 +- .../(logged)/azure_triggers/+page.svelte | 11 +- .../(logged)/email_triggers/+page.svelte | 11 +- .../routes/(root)/(logged)/flows/add/+page.js | 5 - .../(root)/(logged)/flows/add/+page.svelte | 223 +- .../routes/(root)/(logged)/flows/add/+page.ts | 6 + .../flows/edit/[...path]/+page.svelte | 567 +- .../(logged)/flows/get/[...path]/+page.svelte | 4 +- .../(root)/(logged)/gcp_triggers/+page.svelte | 11 +- .../(logged)/kafka_triggers/+page.svelte | 11 +- .../(logged)/mqtt_triggers/+page.svelte | 11 +- .../(logged)/nats_triggers/+page.svelte | 11 +- .../(logged)/postgres_triggers/+page.svelte | 11 +- .../(root)/(logged)/resources/+page.svelte | 18 +- .../(root)/(logged)/routes/+page.svelte | 22 +- .../(root)/(logged)/run/[...run]/+page.svelte | 2 +- .../(root)/(logged)/schedules/+page.svelte | 18 +- .../(root)/(logged)/scripts/add/+page.js | 5 - .../(root)/(logged)/scripts/add/+page.svelte | 307 +- .../(root)/(logged)/scripts/add/+page.ts | 6 + .../scripts/edit/[...path]/+page.svelte | 681 +- .../scripts/get/[...hash]/+page.svelte | 2 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 11 +- .../svix/create-webhook/+page@(root).svelte | 2 +- .../(root)/(logged)/variables/+page.svelte | 18 +- .../(logged)/websocket_triggers/+page.svelte | 11 +- rust-client/src/client.rs | 12 +- .../schemas/azure_trigger.schema.yaml | 20 + .../schemas/email_trigger.schema.yaml | 20 + .../schemas/gcp_trigger.schema.yaml | 20 + .../schemas/http_trigger.schema.yaml | 20 + .../schemas/kafka_trigger.schema.yaml | 20 + .../schemas/mqtt_trigger.schema.yaml | 20 + .../schemas/nats_trigger.schema.yaml | 20 + .../schemas/postgres_trigger.schema.yaml | 20 + .../schemas/schedule.schema.yaml | 16 + .../schemas/sqs_trigger.schema.yaml | 20 + .../schemas/websocket_trigger.schema.yaml | 20 + 243 files changed, 17514 insertions(+), 10993 deletions(-) create mode 100644 backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json create mode 100644 backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json create mode 100644 backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json create mode 100644 backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json create mode 100644 backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json create mode 100644 backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json create mode 100644 backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json create mode 100644 backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json create mode 100644 backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json create mode 100644 backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json create mode 100644 backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json create mode 100644 backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json create mode 100644 backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json create mode 100644 backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json create mode 100644 backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json create mode 100644 backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json create mode 100644 backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json create mode 100644 backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json create mode 100644 backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json create mode 100644 backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json create mode 100644 backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json create mode 100644 backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json create mode 100644 backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json create mode 100644 backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json create mode 100644 backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json rename backend/.sqlx/{query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json => query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json} (76%) create mode 100644 backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json create mode 100644 backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json create mode 100644 backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json create mode 100644 backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json create mode 100644 backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json create mode 100644 backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json create mode 100644 backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json create mode 100644 backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json create mode 100644 backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json create mode 100644 backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json create mode 100644 backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json create mode 100644 backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json create mode 100644 backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json create mode 100644 backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json create mode 100644 backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json create mode 100644 backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json create mode 100644 backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json create mode 100644 backend/migrations/20260528143710_draft_user_sync_schema.down.sql create mode 100644 backend/migrations/20260528143710_draft_user_sync_schema.up.sql create mode 100644 backend/migrations/20260609165313_remove_draft_only.down.sql create mode 100644 backend/migrations/20260609165313_remove_draft_only.up.sql create mode 100644 backend/windmill-common/src/user_drafts.rs create mode 100644 backend/windmill-types/src/user_drafts.rs create mode 100644 frontend/src/lib/coalescingRunner.svelte.ts create mode 100644 frontend/src/lib/coalescingRunner.test.ts create mode 100644 frontend/src/lib/components/AutosaveIndicator.svelte create mode 100644 frontend/src/lib/components/EncryptedDraftField.svelte delete mode 100644 frontend/src/lib/components/apps/editor/AppEditorHeaderDeployInitialDraft.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte delete mode 100644 frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte create mode 100644 frontend/src/lib/components/forkDraftToImport.ts create mode 100644 frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte create mode 100644 frontend/src/lib/components/usePageDraftSync.svelte.ts create mode 100644 frontend/src/lib/debouncerByKey.svelte.ts create mode 100644 frontend/src/lib/debouncerByKey.test.ts create mode 100644 frontend/src/lib/draftAddRedirect.ts create mode 100644 frontend/src/lib/encryptedDraft.ts create mode 100644 frontend/src/lib/localDraftHints.svelte.ts delete mode 100644 frontend/src/lib/userDraft.test.ts create mode 100644 frontend/src/lib/userDraftDbMigration.ts create mode 100644 frontend/src/lib/userDraftDbSyncer.svelte.ts create mode 100644 frontend/src/lib/userNamespace.ts delete mode 100644 frontend/src/routes/(root)/(logged)/apps/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/apps/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/flows/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/flows/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/scripts/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/scripts/add/+page.ts diff --git a/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json new file mode 100644 index 0000000000..71f0c1e2d8 --- /dev/null +++ b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72" +} diff --git a/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json new file mode 100644 index 0000000000..d67ddf711b --- /dev/null +++ b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, (email IS NULL), created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f" +} diff --git a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json index 8944a6001f..dfc8540468 100644 --- a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json +++ b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "id", + "name": "id!", "type_info": "Uuid" } ], @@ -16,7 +16,7 @@ ] }, "nullable": [ - false + null ] }, "hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319" diff --git a/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json new file mode 100644 index 0000000000..900cbdae4d --- /dev/null +++ b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ, value, created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2" +} diff --git a/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json new file mode 100644 index 0000000000..2d22e40cc7 --- /dev/null +++ b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path\n FROM app\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "versions", + "type_info": "Int8Array" + }, + { + "ordinal": 6, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5" +} diff --git a/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json new file mode 100644 index 0000000000..19fbdd36f9 --- /dev/null +++ b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4" +} diff --git a/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json new file mode 100644 index 0000000000..66809be416 --- /dev/null +++ b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe" +} diff --git a/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json new file mode 100644 index 0000000000..f1fab7b130 --- /dev/null +++ b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6" +} diff --git a/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json new file mode 100644 index 0000000000..335561cfef --- /dev/null +++ b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81" +} diff --git a/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json new file mode 100644 index 0000000000..ebd8594a06 --- /dev/null +++ b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5" +} diff --git a/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json new file mode 100644 index 0000000000..e432c394b9 --- /dev/null +++ b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c" +} diff --git a/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json new file mode 100644 index 0000000000..be5e26ef9b --- /dev/null +++ b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a" +} diff --git a/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json new file mode 100644 index 0000000000..37f6872cc9 --- /dev/null +++ b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4" +} diff --git a/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json new file mode 100644 index 0000000000..bb326fab87 --- /dev/null +++ b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND (email = $4 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4" +} diff --git a/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json new file mode 100644 index 0000000000..ca75762cd6 --- /dev/null +++ b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json new file mode 100644 index 0000000000..7ecba622e6 --- /dev/null +++ b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "resource_type", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 9, + "name": "is_expired", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_refreshed", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "refresh_error", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "is_linked", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "is_oauth?", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "account", + "type_info": "Int4" + }, + { + "ordinal": 15, + "name": "ws_specific", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "is_draft", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true, + true, + null, + null, + true, + null, + false, + true, + null, + null, + null + ] + }, + "hash": "665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067" +} diff --git a/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json new file mode 100644 index 0000000000..80f172aa40 --- /dev/null +++ b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Text", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd" +} diff --git a/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json new file mode 100644 index 0000000000..dcff085b5e --- /dev/null +++ b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e" +} diff --git a/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json new file mode 100644 index 0000000000..2b92062ad0 --- /dev/null +++ b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63" +} diff --git a/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json new file mode 100644 index 0000000000..02e17b82cb --- /dev/null +++ b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d" +} diff --git a/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json new file mode 100644 index 0000000000..f4839e8e38 --- /dev/null +++ b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e" +} diff --git a/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json new file mode 100644 index 0000000000..9dc1d0f296 --- /dev/null +++ b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d" +} diff --git a/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json new file mode 100644 index 0000000000..00b6e712d5 --- /dev/null +++ b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780" +} diff --git a/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json new file mode 100644 index 0000000000..33e7bfe8ec --- /dev/null +++ b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5" +} diff --git a/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json new file mode 100644 index 0000000000..13402c8387 --- /dev/null +++ b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422" +} diff --git a/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json new file mode 100644 index 0000000000..681b9d7d7d --- /dev/null +++ b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df" +} diff --git a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json similarity index 76% rename from backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json rename to backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json index 5de7aed2e0..08dfcfd6bd 100644 --- a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json +++ b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", "describe": { "columns": [ { @@ -87,6 +87,16 @@ "ordinal": 16, "name": "ws_specific", "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 18, + "name": "is_draft", + "type_info": "Bool" } ], "parameters": { @@ -112,8 +122,10 @@ null, false, true, + null, + null, null ] }, - "hash": "52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f" + "hash": "9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1" } diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json index f0c91daf16..b492b38f49 100644 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json @@ -13,4 +13,4 @@ "nullable": [] }, "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json new file mode 100644 index 0000000000..52e1ced19a --- /dev/null +++ b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d" +} diff --git a/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json new file mode 100644 index 0000000000..a4ad3fd76b --- /dev/null +++ b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52" +} diff --git a/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json new file mode 100644 index 0000000000..78014926fa --- /dev/null +++ b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2" +} diff --git a/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json new file mode 100644 index 0000000000..65003984ac --- /dev/null +++ b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453" +} diff --git a/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json new file mode 100644 index 0000000000..61c60afdc4 --- /dev/null +++ b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Int8Array", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a" +} diff --git a/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json new file mode 100644 index 0000000000..6d682c7bb7 --- /dev/null +++ b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6" +} diff --git a/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json new file mode 100644 index 0000000000..0eed069163 --- /dev/null +++ b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596" +} diff --git a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json new file mode 100644 index 0000000000..6ae3f60e49 --- /dev/null +++ b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" +} diff --git a/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json new file mode 100644 index 0000000000..35e384b496 --- /dev/null +++ b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d" +} diff --git a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json new file mode 100644 index 0000000000..c077375ef4 --- /dev/null +++ b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Text", + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545" +} diff --git a/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json new file mode 100644 index 0000000000..879288cc04 --- /dev/null +++ b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7, $8,\n $9,\n $10, $11::text::json, $12, now(), $13\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5" +} diff --git a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json new file mode 100644 index 0000000000..319c192fae --- /dev/null +++ b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa" +} diff --git a/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json new file mode 100644 index 0000000000..7ac1fb17da --- /dev/null +++ b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false\n ORDER BY path, created_at DESC\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71" +} diff --git a/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json new file mode 100644 index 0000000000..b30207b10e --- /dev/null +++ b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75" +} diff --git a/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json new file mode 100644 index 0000000000..394d36b747 --- /dev/null +++ b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745" +} diff --git a/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json new file mode 100644 index 0000000000..c624143ea4 --- /dev/null +++ b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8" +} diff --git a/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json new file mode 100644 index 0000000000..e8dedf7ab9 --- /dev/null +++ b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11f00f7ad1..8ca8a7e265 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14589,6 +14589,7 @@ dependencies = [ "dashmap", "datafusion", "equivalent", + "erased-serde", "futures", "futures-core", "gethostname", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 70d6eba003..fa76836c89 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a82882f1cb9b1c4cef532f6ad046242903418d29 +97b5cb2096d3a9b4818943c5abf181d914cb4e99 diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.down.sql b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql new file mode 100644 index 0000000000..bbd036ad37 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql @@ -0,0 +1,25 @@ +ALTER TABLE draft DROP COLUMN id; + +-- The .up.sql secret-draft scrub is irreversible — the blanked plaintext +-- values were deliberately destroyed and cannot be recovered here. + +DROP INDEX IF EXISTS draft_workspace_path_typ_idx; +DROP INDEX IF EXISTS draft_user_listing_idx; +DROP INDEX IF EXISTS draft_pkey_legacy; +DROP INDEX IF EXISTS draft_pkey_with_user; + +-- Per-user rows can't exist under the composite PK (one row per +-- (workspace_id, path, typ)); drop them before restoring it. +DELETE FROM draft WHERE email IS NOT NULL; + +ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); + +ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; +ALTER TABLE draft DROP COLUMN email; + +-- Restore the narrower DRAFT_TYPE enum; drop rows outside that set so the +-- cast doesn't fail. +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); +DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app'); +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE; +DROP TYPE DRAFT_KIND; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.up.sql b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql new file mode 100644 index 0000000000..07343c6b2c --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql @@ -0,0 +1,82 @@ +-- Reshape `draft` for per-user bidirectional sync: add the owner `email` +-- (FK to password.email, NULL on legacy rows); replace the composite PK with +-- two partial unique indexes so per-user rows and the single legacy +-- workspace-level row coexist at the same (workspace_id, path, typ); widen +-- the DRAFT_TYPE enum to DRAFT_KIND (every UserDraftItemKind); and add a +-- synthetic BIGSERIAL `id` PK (tools like pg_dump/replication break on the +-- partial-index-only layout). + +CREATE TYPE DRAFT_KIND AS ENUM ( + 'script', + 'flow', + 'app', + 'raw_app', + 'resource', + 'variable', + 'trigger_schedule', + 'trigger_webhook', + 'trigger_default_email', + 'trigger_email', + 'trigger_http', + 'trigger_websocket', + 'trigger_postgres', + 'trigger_kafka', + 'trigger_nats', + 'trigger_mqtt', + 'trigger_sqs', + 'trigger_gcp', + 'trigger_azure', + 'trigger_poll', + 'trigger_cli', + 'trigger_nextcloud', + 'trigger_google', + 'trigger_github' +); + +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND; +DROP TYPE DRAFT_TYPE; + +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +ALTER TABLE draft + ADD CONSTRAINT draft_password_fkey + FOREIGN KEY (email) + REFERENCES password(email) + ON DELETE CASCADE + ON UPDATE CASCADE; + +ALTER TABLE draft DROP CONSTRAINT draft_pkey; + +CREATE UNIQUE INDEX draft_pkey_with_user + ON draft (workspace_id, path, typ, email) + WHERE email IS NOT NULL; + +CREATE UNIQUE INDEX draft_pkey_legacy + ON draft (workspace_id, path, typ) + WHERE email IS NULL; + +-- Serves the per-user draft listing (`WHERE workspace_id = ? AND email = ? +-- ORDER BY path`); neither partial unique index helps (both lead with +-- `path, typ`), and the trailing `path` keeps rows in output order. +CREATE INDEX draft_user_listing_idx + ON draft (workspace_id, email, path) + WHERE email IS NOT NULL; + +ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; + +-- Hot path: `fetch_other_drafts_users` runs on every get-by-path request +-- with `WHERE workspace_id = ? AND path = ? AND typ = ?` and no email +-- predicate. The partial unique/listing indexes can't serve it (their +-- `email IS [NOT] NULL` predicates aren't implied by the query), so a plain +-- btree is needed. Also covers `get_draft_for_user` (same three columns + +-- `email IS NOT DISTINCT FROM ?` as a filter). +CREATE INDEX draft_workspace_path_typ_idx ON draft (workspace_id, path, typ); + +-- Secret variable values must never sit in `draft.value` in plaintext. +-- `save_draft` now encrypts them at write time; this scrubs any rows +-- persisted before that guard (irreversible — see the .down.sql note). +UPDATE draft +SET value = jsonb_set(value::jsonb, '{variable,value}', '""'::jsonb)::json +WHERE typ = 'variable' + AND (value::jsonb -> 'variable' ->> 'is_secret')::boolean IS TRUE + AND value::jsonb -> 'variable' ? 'value'; diff --git a/backend/migrations/20260609165313_remove_draft_only.down.sql b/backend/migrations/20260609165313_remove_draft_only.down.sql new file mode 100644 index 0000000000..9594c93957 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.down.sql @@ -0,0 +1,5 @@ +-- Restore the `draft_only` column. Irreversible data-wise: stubs deleted in +-- the up migration are gone and the column comes back NULL everywhere. +ALTER TABLE script ADD COLUMN draft_only BOOLEAN; +ALTER TABLE flow ADD COLUMN draft_only BOOLEAN; +ALTER TABLE app ADD COLUMN draft_only BOOLEAN; diff --git a/backend/migrations/20260609165313_remove_draft_only.up.sql b/backend/migrations/20260609165313_remove_draft_only.up.sql new file mode 100644 index 0000000000..9949de5540 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.up.sql @@ -0,0 +1,99 @@ +-- `draft_only` items (scripts/flows/apps saved as a draft but never +-- deployed) lived as a stub row in their own table plus a `draft` row; the +-- stub is now redundant. For each stub: ensure a draft row exists at its +-- path (ON CONFLICT DO NOTHING preserves the real per-user draft most stubs +-- already have, synthesising an `email = NULL` legacy stand-in only for the +-- rare stub that lost its draft), drop the stub (version FKs cascade), then +-- drop the `draft_only` column. +-- ON CONFLICT targets `draft_pkey_legacy` — (workspace_id, path, typ) WHERE +-- email IS NULL. + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + s.workspace_id, + s.path, + 'script'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', s.path, + 'summary', s.summary, + 'description', s.description, + 'content', s.content, + 'language', s.language, + 'kind', s.kind, + 'tag', s.tag, + 'schema', s.schema, + 'envs', to_jsonb(s.envs), + 'concurrent_limit', s.concurrent_limit, + 'concurrency_time_window_s', s.concurrency_time_window_s, + 'concurrency_key', s.concurrency_key, + 'cache_ttl', s.cache_ttl, + 'cache_ignore_s3_path', s.cache_ignore_s3_path, + 'dedicated_worker', s.dedicated_worker, + 'ws_error_handler_muted', s.ws_error_handler_muted, + 'priority', s.priority, + 'timeout', s.timeout, + 'delete_after_use', s.delete_after_use, + 'restart_unless_cancelled', s.restart_unless_cancelled, + 'visible_to_runner_only', s.visible_to_runner_only, + 'auto_kind', s.auto_kind, + 'has_preprocessor', s.has_preprocessor, + 'on_behalf_of_email', s.on_behalf_of_email, + 'assets', s.assets, + 'debounce_key', s.debounce_key, + 'debounce_delay_s', s.debounce_delay_s, + 'labels', to_jsonb(s.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM script s +WHERE s.draft_only IS TRUE AND s.deleted IS FALSE AND s.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + f.workspace_id, + f.path, + 'flow'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', f.path, + 'summary', f.summary, + 'description', f.description, + 'value', f.value, + 'schema', f.schema, + 'tag', f.tag, + 'dedicated_worker', f.dedicated_worker, + 'timeout', f.timeout, + 'visible_to_runner_only', f.visible_to_runner_only, + 'on_behalf_of_email', f.on_behalf_of_email, + 'ws_error_handler_muted', f.ws_error_handler_muted, + 'labels', to_jsonb(f.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM flow f +WHERE f.draft_only IS TRUE AND f.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +-- App drafts store the editor's working value directly in `draft.value` (an +-- `App` object for `app`, a `{files, runnables, data}` object for `raw_app`). +-- The deployed wrapper's summary/policy/custom_path aren't part of the App +-- type and aren't restored from a draft on reload, so they're dropped here. +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + a.workspace_id, + a.path, + CASE WHEN av.raw_app THEN 'raw_app'::DRAFT_KIND ELSE 'app'::DRAFT_KIND END, + NULL, + av.value +FROM app a +JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)] +WHERE a.draft_only IS TRUE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +DELETE FROM script WHERE draft_only IS TRUE; +DELETE FROM flow WHERE draft_only IS TRUE; +DELETE FROM app WHERE draft_only IS TRUE; + +ALTER TABLE script DROP COLUMN draft_only; +ALTER TABLE flow DROP COLUMN draft_only; +ALTER TABLE app DROP COLUMN draft_only; diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs index c8551a30a5..46bed97094 100644 --- a/backend/tests/ci_tests.rs +++ b/backend/tests/ci_tests.rs @@ -10,6 +10,7 @@ mod ci_tests { fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Python3, lock: None, @@ -20,7 +21,6 @@ mod ci_tests { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 48a50ef9b9..0d58615be0 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -16,6 +16,7 @@ mod dependency_map { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -26,7 +27,6 @@ mod dependency_map { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -441,7 +441,6 @@ def main(): .unwrap(), ), schema: None, - draft_only: None, tag: None, dedicated_worker: None, timeout: None, diff --git a/backend/tests/relock_skip.rs b/backend/tests/relock_skip.rs index eb8dfb261d..8262a38cf4 100644 --- a/backend/tests/relock_skip.rs +++ b/backend/tests/relock_skip.rs @@ -14,6 +14,7 @@ mod relock_skip { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -24,7 +25,6 @@ mod relock_skip { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs index 4110bd4ba5..05d22e75ec 100644 --- a/backend/tests/script_auto_kind_failure.rs +++ b/backend/tests/script_auto_kind_failure.rs @@ -6,6 +6,7 @@ use windmill_test_utils::init_client; fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Bun, lock: None, @@ -16,7 +17,6 @@ fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: kind.map(|s| s.to_string()), diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index d1c20da407..c3f6357d4c 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -200,7 +200,7 @@ async fn test_handler_queries_websocket(db: Pool) -> anyhow::Result<() assert_eq!(trigger.base.permissioned_as, "u/test-user"); let triggers = handler - .list_triggers(&mut *conn, "test-workspace", None) + .list_triggers(&mut *conn, "test-workspace", None, None) .await?; assert!(triggers.iter().any(|t| t.base.path == "f/test/handler_ws")); diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index f21862fd38..b220416b55 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4178,8 +4178,8 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { visible_to_runner_only: None, on_behalf_of_email: None, }, - draft_only: None, deployment_message: None, + draft_only: None, }, ) .await diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 823ff22a43..ca988562fd 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -40,6 +40,7 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() .create_script( "test-workspace", &windmill_api_client::types::NewScript { + draft_only: None, content: "export function main() { return 42; }".to_string(), language: windmill_api_client::types::ScriptLang::Bun, path: "f/test_folder/test_script".to_string(), @@ -51,7 +52,6 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() parent_hash: None, schema: Default::default(), is_template: None, - draft_only: None, dedicated_worker: None, ws_error_handler_muted: None, priority: None, diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 4a31589baa..5a9087318b 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -21,7 +21,8 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - utils::{WithStarredInfoQuery, HTTP_CLIENT}, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, + utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, }; @@ -49,7 +50,6 @@ use windmill_common::{ flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, - scripts::Schema, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -68,7 +68,6 @@ pub fn workspaced_service() -> Router { .route("/list_tokens/{*path}", get(list_tokens)) .route("/get/{*path}", get(get_flow_by_path)) .route("/deployment_status/p/{*path}", get(get_deployment_status)) - .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/{*path}", get(get_flow_history)) @@ -130,6 +129,7 @@ async fn list_search_flows( async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -151,10 +151,15 @@ async fn list_flows( "archived", "extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -166,7 +171,8 @@ async fn list_flows( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" + .bind(&authed.email), ) .left() .join("flow_version fv") @@ -195,9 +201,6 @@ async fn list_flows( sqlb.and_where_is_not_null("favorite.path"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("o.draft_only IS NOT TRUE"); - } if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } @@ -221,13 +224,92 @@ async fn list_flows( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); - let rows = sqlx::query_as::<_, ListableFlow>(&sql) + let mut rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.edited_by.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` + `DISTINCT ON (path)` ordered NULL-last; see scripts.rs. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'flow' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM flow f + WHERE f.workspace_id = draft.workspace_id + AND f.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), + // so the editor writes a separate `draft_path` field only when the typed + // path differs from the deployed one. `None` = unchanged. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableFlow { + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + edited_by: Some(authed.email.clone()), + edited_at: Some(row.created_at), + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + starred: false, + draft_only: Some(true), + ws_error_handler_muted: None, + deployment_msg: None, + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -517,22 +599,21 @@ async fn create_flow( sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, - dependency_job, lock_error_logs, draft_only, tag, + dependency_job, lock_error_logs, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, - NULL, '', $5, $6, - $7, $8, $9, - $10, - $11, $12::text::json, $13, now(), $14 + NULL, '', $5, + $6, $7, $8, + $9, + $10, $11::text::json, $12, now(), $13 )"#, w_id, nf.path, nf.summary, nf.description.as_deref().unwrap_or(""), - nf.draft_only, nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), @@ -571,12 +652,15 @@ async fn create_flow( ).execute(&mut *tx).await?; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", nf.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -719,18 +803,6 @@ async fn check_schedule_conflict<'c>( Ok(()) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", - "flow", - ) - .await; -} - #[derive(Serialize)] pub struct FlowVersion { pub id: i64, @@ -799,7 +871,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -850,7 +922,6 @@ async fn get_flow_version_by_id( flow.description, flow.archived, flow.extra_perms, - flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, @@ -987,7 +1058,6 @@ async fn update_flow( description = $3, dependency_job = NULL, lock_error_logs = '', - draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, @@ -1029,8 +1099,8 @@ async fn update_flow( // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1174,12 +1244,15 @@ async fn update_flow( } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", flow_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1397,12 +1470,21 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetFlowByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + async fn get_flow_by_path( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1416,9 +1498,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1444,7 +1525,7 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1458,9 +1539,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1481,90 +1561,26 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct FlowWDraft { - pub path: String, - pub summary: String, - pub description: String, - pub schema: Option, - pub value: sqlx::types::Json>, - pub extra_perms: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, -} - -async fn get_flow_by_path_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = sqlx::query_as::<_, FlowWDraft>( - "SELECT - flow.path, - flow.summary, - flow.description, - flow_version.schema, - flow_version.value, - flow.extra_perms, - flow.draft_only, - flow.ws_error_handler_muted, - flow.dedicated_worker, - draft.value AS draft, - draft.created_at AS draft_created_at, - flow.tag, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow.labels - FROM flow - LEFT JOIN draft - ON flow.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'flow' - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 - AND flow.workspace_id = $2", + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Flow, + path, + query.get_draft, + flow_o, + || windmill_common::error::Error::NotFound(format!("Flow not found at path {path}")), ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) + Ok(Json(overlay)) } async fn exists_flow_by_path( diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index 63bd96345b..3d70c09ce1 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_app").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_app"); - // --- get lite --- let resp = authed_get(port, "get/lite", "u/test-user/test_app").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index d86c91b6a1..b3bac8d010 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -1,105 +1,297 @@ use serde_json::json; use sqlx::{Pool, Postgres}; - use windmill_test_utils::*; -fn client() -> reqwest::Client { - reqwest::Client::new() +const WS: &str = "test-workspace"; + +/// A reqwest client that sends `Authorization: Bearer `. The base +/// fixture seeds: SECRET_TOKEN (test-user, admin), SECRET_TOKEN_2 +/// (test-user-2, non-admin), SECRET_TOKEN_3 (test-user-3, non-admin). +fn client_for(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() } -fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - builder.header("Authorization", "Bearer SECRET_TOKEN") +fn save_url(port: u16, kind: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/{WS}/drafts/update/{kind}/{path}") } +async fn draft_count(db: &Pool, path: &str, kind: &str, email: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = $3::text::DRAFT_KIND AND email = $4", + ) + .bind(WS) + .bind(path) + .bind(kind) + .bind(email) + .fetch_one(db) + .await + .unwrap() +} + +/// Upsert → conflict (stale last_sync) → force-overwrite → delete, the +/// optimistic-concurrency contract `update_draft` exists to enforce. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_draft_endpoints(db: Pool) -> anyhow::Result<()> { +async fn test_update_draft_conflict_lifecycle(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - let base = format!("http://localhost:{port}/api/w/test-workspace/drafts"); + let c = client_for("SECRET_TOKEN"); + let path = "u/test-user/draft_x"; + let url = save_url(port, "script", path); - // create a script first so the draft has a valid path - let resp = authed(client().post(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/create" - ))) - .json(&json!({ - "path": "u/test-user/draft_script", - "summary": "Script for draft test", - "description": "", - "content": "export async function main() { return 1; }", - "language": "deno", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": {}, - "required": [] - } - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); - - // --- create draft --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 2; }", - "language": "deno" - } - })) + // First save: no last_sync ("treat as fresh") → saved. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 1 } })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?); + .await?; + assert_eq!(r.status(), 200, "first save"); + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved"); + let ts1 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 1 + ); - // verify draft exists via script get/draft endpoint - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_object(), "expected draft to be present"); + // A tiny gap so the next now() is strictly greater than ts1. + tokio::time::sleep(std::time::Duration::from_millis(15)).await; - // --- update draft (create with same path overwrites) --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 3; }", - "language": "deno" - } - })) + // Save with the matching last_sync → not stale → saved, newer ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 2 }, "last_sync": ts1 })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201); + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved", "in-order save"); + let ts2 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_ne!(ts1, ts2, "timestamp should advance"); - // --- delete draft --- - let resp = authed(client().delete(format!( - "{base}/delete/script/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // Save with the now-stale ts1 → conflict, server reports its current ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1 })) + .send() + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "conflict", "stale save must conflict"); + assert_eq!(body["current_timestamp"].as_str().unwrap(), ts2); - // verify draft is gone - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_null(), "expected draft to be deleted"); + // The conflicting write must NOT have landed — value is still {n:2}. + let stored: serde_json::Value = sqlx::query_scalar::<_, sqlx::types::Json>( + "SELECT value FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = 'script' AND email = 'test@windmill.dev'", + ) + .bind(WS) + .bind(path) + .fetch_one(&db) + .await? + .0; + assert_eq!(stored["n"], 2, "conflicting write must be rejected"); + + // force = true overrides the conflict check. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1, "force": true })) + .send() + .await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "force" + ); + + // Delete (value: null) → saved, row gone. + let r = c.post(&url).json(&json!({ "value": null })).send().await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "delete" + ); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 0, + "row removed after delete" + ); + + Ok(()) +} + +/// require_can_write_path: own namespace allowed, another user's namespace +/// rejected, operators rejected outright. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let user2 = client_for("SECRET_TOKEN_2"); // test-user-2, non-admin + + // Own namespace → allowed. + let r = user2 + .post(save_url(port, "script", "u/test-user-2/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 200, "own namespace allowed"); + + // Another user's namespace, no grant → rejected. + let r = user2 + .post(save_url(port, "script", "u/test-user/theirs")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "other user's namespace rejected"); + + // Operators can't save drafts at all. + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = $1 AND username = 'test-user-3'", + ) + .bind(WS) + .execute(&db) + .await?; + let op = client_for("SECRET_TOKEN_3"); + let r = op + .post(save_url(port, "script", "u/test-user-3/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "operator rejected"); + + Ok(()) +} + +/// The item-level extra_perms fallback: a user granted write on a deployed +/// item (via the Share dialog) can save a draft on it even though it's +/// outside their namespace. Regression test for the authz drop. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_extra_perms_writer(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let path = "u/test-user/shared"; + + // A deployed script owned by test-user, shared with test-user-2 (write). + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 1, $2, '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": true}'::jsonb, 'test-user')", + ) + .bind(WS) + .bind(path) + .execute(&db) + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + let r = user2 + .post(save_url(port, "script", path)) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!( + r.status(), + 200, + "extra_perms writer can save a draft: {}", + r.text().await? + ); + + // Without a grant on a different shared item → still rejected. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 2, 'u/test-user/private', '', '', 'x', 'deno', '{}'::jsonb, \ + '{}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/private")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "no grant → rejected"); + + // A READ-ONLY grant (`extra_perms` value false) must not allow draft + // saves: the write check defers to RLS via `SELECT ... FOR UPDATE`, + // and locking applies the UPDATE policies — visibility under the + // SELECT policy alone isn't enough. Pins the FOR UPDATE semantics the + // probe relies on. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 3, 'u/test-user/readonly', '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": false}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/readonly")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "read-only grant → rejected"); + + Ok(()) +} + +/// Cross-user draft viewing (`GET /drafts/get/{kind}/{path}`) is disabled +/// for the drawer kinds (resource/variable/triggers) so a viewer can't +/// read another user's draft; it stays available for script/flow/app. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_cross_user_draft_privacy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // test-user saves a variable draft and a script draft in a shared folder. + let admin = client_for("SECRET_TOKEN"); + admin + .post(save_url(port, "variable", "f/shared/v")) + .json(&json!({ "value": { "variable": { "value": "x", "is_secret": false } } })) + .send() + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + // Variable is a drawer kind → cross-user view is forbidden regardless of + // path access (the kind gate fires first). + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/variable/f/shared/v?username=test-user" + )) + .send() + .await?; + assert_eq!( + r.status(), + 404, + "variable drafts are private to their owner" + ); + + // Sharing kinds (script) are NOT gated by the kind check — a missing + // draft / no access yields 404 too, but the "private to their owner" + // wording is specific to the drawer kinds, so assert it's absent here. + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/script/f/shared/s?username=test-user" + )) + .send() + .await?; + let body = r.text().await?; + assert!( + !body.contains("private to their owner"), + "script kind must not be blocked by the cross-user privacy gate: {body}" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/flows.rs b/backend/windmill-api-integration-tests/tests/flows.rs index b6075c8e69..774c118e7b 100644 --- a/backend/windmill-api-integration-tests/tests/flows.rs +++ b/backend/windmill-api-integration-tests/tests/flows.rs @@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_flow"); - // --- list --- let resp = authed(client().get(format!("{base}/list"))) .send() diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index c374b757a4..e5f6cb8aa8 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let body = resp.json::().await?; assert_eq!(body["path"], "u/test-user/test_script"); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_script").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_script"); - // --- raw by path (requires language extension) --- let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index 82447daee2..a0f7f7d460 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -92,8 +92,8 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re // Create app sqlx::query!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only) - VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)" + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')" ) .execute(&db) .await?; diff --git a/backend/windmill-api-openapi/Cargo.toml b/backend/windmill-api-openapi/Cargo.toml index ee1727c059..24a6f2aed8 100644 --- a/backend/windmill-api-openapi/Cargo.toml +++ b/backend/windmill-api-openapi/Cargo.toml @@ -11,7 +11,12 @@ path = "src/lib.rs" [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } -windmill-store.workspace = true +# `try_get_resource_from_db_as` is used unconditionally below but is +# cfg-gated behind a trigger feature in windmill-store; forward +# `http_trigger` so the import resolves even when this crate's targets are +# built in isolation (e.g. `--all-targets` under resolver 2), not only via +# whole-workspace feature unification. +windmill-store = { workspace = true, features = ["http_trigger"] } windmill-trigger-http.workspace = true anyhow.workspace = true axum.workspace = true diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 35f71e561e..7cc0111c28 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -25,6 +25,10 @@ use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, + user_drafts::{ + delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only, + UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{ escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath, }, @@ -678,6 +682,9 @@ pub struct ListScheduleQuery { pub summary: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] @@ -695,6 +702,15 @@ pub struct ScheduleLight { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the schedules page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -703,6 +719,7 @@ pub struct ScheduleLight { async fn list_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lsq): Query, ) -> JsonResult> { @@ -724,13 +741,20 @@ async fn list_schedule( "labels", "folder_labels(workspace_id, path) as inherited_labels", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = schedule.workspace_id \ + AND draft.path = schedule.path AND draft.typ = 'trigger_schedule' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); - if let Some(path) = lsq.path { - sqlb.and_where_eq("script_path", "?".bind(&path)); + if let Some(path) = lsq.path.as_ref() { + sqlb.and_where_eq("script_path", "?".bind(path)); } if let Some(is_flow) = lsq.is_flow { sqlb.and_where_eq("is_flow", "?".bind(&is_flow)); @@ -773,10 +797,96 @@ async fn list_schedule( } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let rows = sqlx::query_as::<_, ScheduleLight>(&sql) + let mut rows = sqlx::query_as::<_, ScheduleLight>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + + // Append the authed user's draft-only schedules; see scripts.rs. + if lsq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lsq.path.is_none() + && lsq.is_flow.is_none() + && lsq.args.is_none() + && lsq.path_start.is_none() + && lsq.schedule_path.is_none() + && lsq.description.is_none() + && lsq.summary.is_none() + && lsq.broad_filter.is_none() + && lsq.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + ) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() { + continue; + } + let schedule = v + .get("schedule") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let timezone = v + .get("timezone") + .and_then(|x| x.as_str()) + .unwrap_or("UTC") + .to_string(); + let script_path = v + .get("script_path") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let is_flow = v.get("is_flow").and_then(|x| x.as_bool()).unwrap_or(false); + let enabled = v.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + let summary = v + .get("summary") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + + rows.push(ScheduleLight { + workspace_id: w_id.clone(), + path, + edited_by: String::new(), + edited_at: row.created_at, + schedule, + timezone, + enabled, + script_path, + is_flow, + summary, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -839,16 +949,28 @@ async fn list_schedule_with_jobs( async fn get_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("schedules:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?; - let schedule = not_found_if_none(schedule_o, "Schedule", path)?; tx.commit().await?; - Ok(Json(schedule)) + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + path, + q.get_draft, + schedule_o, + || Error::NotFound(format!("Schedule not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn exists_schedule( @@ -1137,6 +1259,9 @@ async fn delete_schedule( tx.commit().await?; + // Schedule gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::TriggerSchedule, path).await?; + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index ce375fa64f..944329c316 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -13,6 +13,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::{ + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -33,7 +34,6 @@ use itertools::Itertools; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; use serde_json::json; -use serde_json::value::RawValue; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; @@ -45,7 +45,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_common::{ assets::{ clear_static_asset_usage, clear_static_asset_usage_by_script_hash, - insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType, + insert_static_asset_usage, AssetUsageKind, }, error::{self, to_anyhow}, min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, @@ -82,122 +82,6 @@ use windmill_queue::{ const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; -#[derive(Serialize, sqlx::FromRow)] -pub struct ScriptWDraft { - pub hash: ScriptHash, - pub path: String, - pub summary: String, - pub description: String, - pub content: String, - pub language: ScriptLang, - pub kind: ScriptKind, - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub envs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ttl: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ignore_s3_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_unless_cancelled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_use: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_secs: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_preprocessor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub assets: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub modules: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, - #[serde(flatten)] - #[sqlx(flatten)] - pub runnable_settings: SR, -} - -impl ScriptWDraft { - pub async fn prefetch_cached<'a>( - self, - db: &DB, - ) -> error::Result> { - let (debouncing_settings, concurrency_settings) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - self.runnable_settings.runnable_settings_handle, - db, - ) - .await?; - - Ok(ScriptWDraft { - runnable_settings: ScriptRunnableSettingsInline { - concurrency_settings: concurrency_settings.maybe_fallback( - self.runnable_settings.concurrency_key, - self.runnable_settings.concurrent_limit, - self.runnable_settings.concurrency_time_window_s, - ), - debouncing_settings: debouncing_settings.maybe_fallback( - self.runnable_settings.debounce_key, - self.runnable_settings.debounce_delay_s, - ), - }, - hash: self.hash, - path: self.path, - summary: self.summary, - description: self.description, - content: self.content, - language: self.language, - kind: self.kind, - tag: self.tag, - draft: self.draft, - draft_created_at: self.draft_created_at, - schema: self.schema, - draft_only: self.draft_only, - envs: self.envs, - cache_ttl: self.cache_ttl, - cache_ignore_s3_path: self.cache_ignore_s3_path, - dedicated_worker: self.dedicated_worker, - ws_error_handler_muted: self.ws_error_handler_muted, - priority: self.priority, - restart_unless_cancelled: self.restart_unless_cancelled, - delete_after_use: self.delete_after_use, - delete_after_secs: self.delete_after_secs, - timeout: self.timeout, - visible_to_runner_only: self.visible_to_runner_only, - auto_kind: self.auto_kind, - has_preprocessor: self.has_preprocessor, - on_behalf_of_email: self.on_behalf_of_email, - assets: self.assets, - modules: self.modules, - labels: self.labels, - }) - } -} - pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) @@ -222,7 +106,6 @@ pub fn workspaced_service() -> Router { .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) .route("/archive/p/{*path}", post(archive_script_by_path)) - .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) .route("/get/p/{*path}", get(get_script_by_path)) .route("/list_tokens/{*path}", get(list_tokens)) .route("/raw/p/{*path}", get(raw_script_by_path)) @@ -295,6 +178,7 @@ async fn list_search_scripts( async fn list_scripts( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -305,7 +189,7 @@ async fn list_scripts( "hash", "o.path", "summary", - "COALESCE(draft.created_at, o.created_at) as created_at", + "o.created_at as created_at", "archived", "extra_perms", if !lq.without_description.unwrap_or(false) { @@ -317,13 +201,22 @@ async fn list_scripts( "language", "favorite.path IS NOT NULL as starred", "tag", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "auto_kind", "codebase IS NOT NULL as use_codebase", "kind", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Canonical reference for the draft-feature comments; flows/apps point here. + // Per-path draft owners as a JSON array (`Json>`); NULL -> None, + // never an empty array. LEFT JOIN `usr` keeps orphaned drafts (user left workspace) + // visible with `username = None`. In the `admins` workspace username IS the email, + // so fall back to `d.email` there or the authed user's own draft resolves to a phantom + // "Legacy workspace draft"; the genuine NULL-email legacy row stays None. + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -335,7 +228,8 @@ async fn list_scripts( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?" + .bind(&authed.email), ) .order_desc("favorite.path IS NOT NULL") .order_by("created_at", lq.order_desc.unwrap_or(true)) @@ -361,10 +255,6 @@ async fn list_scripts( sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("draft_only IS NOT TRUE"); - } - if lq.show_archived.unwrap_or(false) { sqlb.and_where_eq( "o.ctid", @@ -434,7 +324,7 @@ async fn list_scripts( .fields(&["dm.deployment_msg"]); } - if let Some(languages) = lq.languages { + if let Some(languages) = &lq.languages { sqlb.and_where_in( "language", &languages @@ -447,13 +337,114 @@ async fn list_scripts( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "scripts", "read"); - let rows = sqlx::query_as::<_, ListableScript>(&sql) + let mut rows = sqlx::query_as::<_, ListableScript>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Canonical reference for draft-only synthesis; the other kinds point here. + // Append the authed user's drafts at paths with no deployed script. Gated on + // `include_draft_only` so picker callers stay deployed-only (home page opts in); + // skipped past page 0 or under any narrowing filter to keep pagination clean. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.created_by.is_none() + && lq.first_parent_hash.is_none() + && lq.last_parent_hash.is_none() + && lq.parent_hash.is_none() + && lq.is_template.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && lq.languages.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` surfaces the user's own draft-only rows plus + // legacy NULL-email workspace rows; `DISTINCT ON (path)` ordered `email IS NULL` + // last collapses a path holding both to the owned row. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'script' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM script s + WHERE s.workspace_id = draft.workspace_id + AND s.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + let language: ScriptLang = v + .get("language") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let kind: ScriptKind = v + .get("kind") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or(ScriptKind::Script); + // Scripts bind the Path widget to `script.path`, so the typed path + // round-trips through the draft JSON's own `path` (no `draft_path` field). + let draft_path = v + .get("path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableScript { + hash: ScriptHash(0), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + created_at: row.created_at, + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + language, + starred: false, + tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + draft_only: Some(true), + has_deploy_errors: false, + ws_error_handler_muted: None, + auto_kind: None, + use_codebase: false, + deployment_msg: None, + kind, + labels: None, + // Synthesized rows have no deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft (single-user case). + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -721,7 +712,6 @@ async fn is_noop_deploy_against_parent( language, kind, tag, - draft_only, envs, concurrency_settings, debouncing_settings, @@ -798,7 +788,6 @@ async fn is_noop_deploy_against_parent( || visible_to_runner_only != &parent.visible_to_runner_only || has_preprocessor != &parent.has_preprocessor || is_template.unwrap_or(false) != parent.is_template.unwrap_or(false) - || draft_only.unwrap_or(false) != parent.draft_only.unwrap_or(false) { return Ok(false); } @@ -1026,20 +1015,10 @@ async fn create_script_internal<'c>( let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), - (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( + (None, Some(s)) => Err(Error::BadRequest(format!( "Path conflict for {} with non-archived hash {}", &ns.path, &s.hash ))), - (None, Some(s)) => { - sqlx::query!( - "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - s.hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - Ok(None) - } (Some(p_hash), o) => { // Lock the parent row to prevent concurrent updates with the same parent_hash // This ensures linear lineage - only one script can have a given parent at a time @@ -1292,10 +1271,10 @@ async fn create_script_internal<'c>( sqlx::query!( "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", &w_id, &hash.0, ns.path, @@ -1311,7 +1290,6 @@ async fn create_script_internal<'c>( lang as ScriptLang, ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, ns.tag, - ns.draft_only, envs, guarded_concurrent_limit, guarded_concurrency_time_window_s, @@ -1376,10 +1354,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { + // Canonical: on deploy only wipe the deployer's own draft (plus the legacy + // NULL-email row). Teammates' drafts are independent — they stay and fire the + // StaleDraftModal on the teammate's next reload rather than vanishing silently. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", p_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1463,10 +1446,14 @@ async fn create_script_internal<'c>( } } } else if !skip_draft_deletion { + // See the matching branch above — only wipe the deployer's own + // draft (plus the legacy NULL-email row). sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", ns.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1773,14 +1760,25 @@ pub async fn pick_hub_script_by_path( Ok::<_, Error>((status_code, headers, response)) } +// Canonical: fields inlined rather than `#[serde(flatten)]` from +// `WithStarredInfoQuery` / `WithDraftQuery`. axum's `serde_urlencoded` extractor +// drops type info through flatten, so `?get_draft=true` arrives as a String and +// fails the inner bool deserializer. Inlining lets the bool adapter see it directly. +#[derive(Deserialize)] +struct GetScriptByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + #[axum::debug_handler] async fn get_script_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1800,7 +1798,7 @@ async fn get_script_by_path( ORDER BY s.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1812,19 +1810,33 @@ async fn get_script_by_path( ), ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let script = windmill_common::scripts::prefetch_cached_script_with_starred( - not_found_if_none(script_o, "Script", path)?, + // Canonical: with no deployed row and `get_draft` set, fall back to the draft + // table so editing a never-deployed draft works like a deployed reload. + let deployed = match script_o { + Some(script_o) => Some( + windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db).await?, + ), + None => None, + }; + let overlay = overlay_or_draft_only( &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.get_draft, + deployed, + || windmill_common::error::Error::NotFound(format!("Script not found at path {path}")), ) .await?; - Ok(Json(script)) + Ok(Json(overlay)) } async fn list_tokens( @@ -1835,32 +1847,6 @@ async fn list_tokens( list_tokens_internal(&db, &w_id, &path, false).await } -async fn get_script_by_path_w_draft( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' - WHERE script.path = $1 AND script.workspace_id = $2 - ORDER BY script.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)?; - Ok(Json(script.prefetch_cached(&db).await?)) -} - async fn get_script_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -2521,18 +2507,6 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - "script", - ) - .await; -} - async fn archive_script_by_path( authed: ApiAuthed, Extension(webhook): Extension, @@ -2769,18 +2743,7 @@ async fn delete_script_by_path( let mut tx = user_db.begin(&authed).await?; - let draft_only = sqlx::query_scalar!( - "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - if !draft_only { - require_admin(authed.is_admin, &authed.username)?; - } + require_admin(authed.is_admin, &authed.username)?; // Capture all script versions and drafts for trashbin before deleting let trash_scripts: Vec = sqlx::query_scalar( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index e0037861d3..d809cc1343 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3844,10 +3844,15 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } +// `authed_email` is the forker's email — `clone_drafts` only carries this +// user's per-user drafts (and the legacy NULL-email workspace draft, if any) +// across, since other users aren't added to the fork's `usr` table and +// their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed_email: &str, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -3888,6 +3893,14 @@ async fn clone_workspace_data( // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + // Clone the forker's own per-user drafts (plus the legacy NULL-email + // workspace draft, if any) so they keep their pending edits in the + // fork. Other users' drafts are intentionally NOT cloned — they don't + // own a `usr` row in the fork (see `clone_workspace_full`) so their + // drafts would dangle and the home-page `draft_users` aggregate would + // surface them as duplicate legacy entries. + clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -4364,7 +4377,7 @@ async fn clone_scripts( r#"INSERT INTO script ( workspace_id, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4374,7 +4387,7 @@ async fn clone_scripts( SELECT $1, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4417,12 +4430,12 @@ async fn clone_flows( sqlx::query!( "INSERT INTO flow ( workspace_id, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, dependency_job, draft_only, tag, + archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, on_behalf_of_email, lock_error_logs ) SELECT $2, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, NULL, draft_only, tag, + archived, schema, extra_perms, NULL, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs FROM flow @@ -4503,7 +4516,7 @@ async fn clone_apps( ) -> Result> { // Get all apps from source workspace let apps = sqlx::query!( - "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path FROM app WHERE workspace_id = $1", source_workspace_id @@ -4516,8 +4529,8 @@ async fn clone_apps( // Clone apps with new IDs for app in apps { let new_app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", target_workspace_id, app.path, @@ -4525,7 +4538,6 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.draft_only, app.custom_path, ) .fetch_one(&mut **tx) @@ -4729,6 +4741,39 @@ async fn clone_raw_apps( Ok(()) } +/// Clone every per-user draft (and the legacy NULL-email workspace draft, +/// if present) from the parent. The fork target is empty at create time so +/// a plain INSERT is safe — no need to UPSERT against the partial unique +/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the +/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list +/// it in the column set. `created_at` is preserved so the per-tab +/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's +/// `draft_saved_at`) lines up with the parent's timeline — otherwise the +/// fork's first POST from any open editor would race a stale `last_sync` +/// and trip the conflict modal on every cloned draft. +// Only `email = authed_email` and the legacy NULL row are cloned — see +// `clone_workspace_data` for the rationale. +async fn clone_drafts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + authed_email: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + SELECT $2, path, typ, value, created_at, email + FROM draft + WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + source_workspace_id, + target_workspace_id, + authed_email, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -5023,7 +5068,7 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id, &authed.email).await?; // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener @@ -6972,7 +7017,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", source_workspace_id, path ) @@ -6984,7 +7029,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", fork_workspace_id, path ) @@ -7430,7 +7475,7 @@ async fn get_cloud_quotas( let scripts_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", &w_id @@ -7525,7 +7570,7 @@ async fn prune_versions( "DELETE FROM script WHERE workspace_id = $1 AND hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 55473a8861..45cac3a37d 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -279,8 +279,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 00727e626f..0cf2e9aca0 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.713.1", + "version": "1.723.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -7879,6 +7879,54 @@ } } }, + "/users/tokens/update_label/{token_prefix}": { + "post": { + "summary": "update label of an existing token (owner only)", + "operationId": "updateTokenLabel", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new label (null or omitted = no label)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "label updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8202,6 +8250,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -8210,7 +8261,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListableVariable" + "allOf": [ + { + "$ref": "#/components/schemas/ListableVariable" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -8350,6 +8408,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft variables whose path has no\ndeployed variable. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -10264,6 +10331,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -10272,7 +10342,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Resource" + "allOf": [ + { + "$ref": "#/components/schemas/ListableResource" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -10507,6 +10584,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -12005,7 +12091,37 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "type": "object", + "properties": { + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nscript — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Surfaced for draft-only rows so\nthe home list can render the meaningful name\ninstead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + } + } + } + ] } } } @@ -12043,10 +12159,10 @@ } } }, - "/w/{workspace}/drafts/create": { - "post": { - "summary": "create draft", - "operationId": "createDraft", + "/w/{workspace}/drafts/list": { + "get": { + "summary": "list every draft the current user has in this workspace, across all kinds", + "operationId": "listDrafts", "tags": [ "draft" ], @@ -12055,42 +12171,42 @@ "$ref": "#/components/parameters/WorkspaceId" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "typ": { - "type": "string", - "enum": [ - "flow", - "script", - "app" - ] - }, - "value": {} - }, - "required": [ - "path", - "typ", - "enum" - ] - } - } - } - }, "responses": { - "201": { - "description": "draft created", + "200": { + "description": "the user's drafts", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/UserDraftItemKind" + }, + "path": { + "type": "string" + }, + "summary": { + "type": "string", + "description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one." + }, + "draft_only": { + "type": "boolean", + "description": "No deployed counterpart exists at this path — the draft is the whole item." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kind", + "path", + "draft_only", + "created_at" + ] + } } } } @@ -12098,10 +12214,10 @@ } } }, - "/w/{workspace}/drafts/delete/{kind}/{path}": { - "delete": { - "summary": "delete draft", - "operationId": "deleteDraft", + "/w/{workspace}/drafts/get/{kind}/{path}": { + "get": { + "summary": "fetch a single draft's content by workspace username (or the legacy workspace-level row)", + "operationId": "getDraftForUser", "tags": [ "draft" ], @@ -12114,25 +12230,122 @@ "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "script", - "flow", - "app" - ] + "$ref": "#/components/schemas/UserDraftItemKind" + } + }, + { + "$ref": "#/components/parameters/ScriptPath" + }, + { + "name": "username", + "in": "query", + "required": false, + "description": "Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "draft content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value", + "created_at" + ] + } + } + } + }, + "404": { + "description": "no draft for that owner at that path" + } + } + } + }, + "/w/{workspace}/drafts/update/{kind}/{path}": { + "post": { + "summary": "upsert (or clear) the current user's draft at a path", + "operationId": "updateDraft", + "tags": [ + "draft" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "kind", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserDraftItemKind" } }, { "$ref": "#/components/parameters/ScriptPath" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules." + }, + "last_sync": { + "type": "string", + "format": "date-time", + "description": "Server timestamp of the client's last known sync for this draft. Omit on first save." + }, + "force": { + "type": "boolean", + "description": "Skip the conflict check and overwrite the server copy." + } + } + } + } + } + }, "responses": { "200": { - "description": "draft deleted", + "description": "save result", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "saved", + "conflict" + ] + }, + "current_timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "current_timestamp" + ] } } } @@ -12729,6 +12942,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -12737,7 +12953,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -12806,35 +13029,6 @@ } } }, - "/w/{workspace}/scripts/get/draft/{path}": { - "get": { - "summary": "get script by path with draft", - "operationId": "getScriptByPathWithDraft", - "tags": [ - "script" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "script details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewScriptWithDraft" - } - } - } - } - } - } - }, "/w/{workspace}/scripts/history/p/{path}": { "get": { "summary": "get history of a script by path", @@ -14541,6 +14735,42 @@ } } }, + "/w/{workspace}/jobs/job_view_token/{id}": { + "get": { + "summary": "mint a read-only share token for a job", + "description": "Returns a stateless `{job_id}.{hmac}` token that grants an authenticated workspace member read access to this job (and its flow subtree) via a `view_token` query param or `X-View-Token` header. Only callable by a user who can already read the job.\n", + "operationId": "getJobViewToken", + "tags": [ + "job" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "the share read token", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/flows/list_paths": { "get": { "summary": "list all flow paths", @@ -14722,11 +14952,29 @@ { "type": "object", "properties": { - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nflow — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } } } } @@ -14950,6 +15198,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -14958,7 +15209,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "allOf": [ + { + "$ref": "#/components/schemas/Flow" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -15110,52 +15368,6 @@ } } }, - "/w/{workspace}/flows/get/draft/{path}": { - "get": { - "summary": "get flow by path with draft", - "operationId": "getFlowByPathWithDraft", - "tags": [ - "flow" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "flow details with draft", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Flow" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/Flow" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - } - } - } - } - } - } - }, "/w/{workspace}/flows/exists/{path}": { "get": { "summary": "exists flow by path", @@ -15224,9 +15436,6 @@ { "type": "object", "properties": { - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -15643,6 +15852,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -16059,9 +16271,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16140,9 +16349,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16245,6 +16451,17 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" + }, + { + "name": "raw_app", + "in": "query", + "description": "When no deployed app exists at this path and `get_draft` is set,\ndisambiguates which draft kind (`raw_app` or `app`) to look up.\nIgnored when a deployed row exists.\n", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -16253,7 +16470,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppWithLastVersion" + "allOf": [ + { + "$ref": "#/components/schemas/AppWithLastVersion" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -16290,35 +16514,6 @@ } } }, - "/w/{workspace}/apps/get/draft/{path}": { - "get": { - "summary": "get app by path with draft", - "operationId": "getAppByPathWithDraft", - "tags": [ - "app" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "app details with draft", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppWithLastVersionWDraft" - } - } - } - } - } - } - }, "/w/{workspace}/apps/history/p/{path}": { "get": { "summary": "get app history by path", @@ -18545,6 +18740,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -18962,6 +19171,20 @@ { "$ref": "#/components/parameters/Success" }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "$ref": "#/components/parameters/JobKinds" }, @@ -19345,6 +19568,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -21488,6 +21725,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21496,7 +21736,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "allOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -21618,6 +21865,15 @@ "type": "string" }, "description": "Filter by label" + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft schedules whose path has\nno deployed schedule. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -21966,6 +22222,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21974,7 +22233,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HttpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/HttpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22030,6 +22296,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22303,6 +22572,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22311,7 +22583,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebsocketTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/WebsocketTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22367,6 +22646,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22638,6 +22920,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22646,7 +22931,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KafkaTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/KafkaTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22702,6 +22994,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23039,6 +23334,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23047,7 +23345,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NatsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/NatsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23103,6 +23408,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23367,6 +23675,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23375,7 +23686,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SqsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/SqsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23431,6 +23749,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24145,6 +24466,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24620,6 +24944,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24628,7 +24955,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MqttTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/MqttTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -24684,6 +25018,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24948,6 +25285,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24956,7 +25296,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/GcpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25012,6 +25359,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -25389,6 +25739,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -25397,7 +25750,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AzureTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/AzureTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25443,6 +25803,9 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26302,6 +26665,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26310,7 +26676,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostgresTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/PostgresTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26366,6 +26739,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26630,6 +27006,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26638,7 +27017,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/EmailTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26694,6 +27080,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -27667,6 +28056,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -27729,6 +28124,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -31494,6 +31895,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -32604,6 +33019,24 @@ } }, "parameters": { + "GetDraft": { + "name": "get_draft", + "in": "query", + "required": false, + "description": "When true, overlay the authed user's draft (if any) onto the deployed payload.", + "schema": { + "type": "boolean" + } + }, + "IncludeDraftOnly": { + "name": "include_draft_only", + "in": "query", + "required": false, + "description": "When true, append per-user draft rows whose path has no\ndeployed counterpart. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge. Gated to\nnon-operators + page 0 + no narrowing filters on the backend so\npicker callers stay deployed-only and pagination stays clean.\n", + "schema": { + "type": "boolean" + } + }, "Id": { "name": "id", "in": "path", @@ -33148,6 +33581,73 @@ } }, "schemas": { + "UserDraftOverlay": { + "type": "object", + "description": "Overlay fields added to every \"get by path\" response that accepts\nthe `get_draft` query parameter. The deployed payload is sent\nuntouched in the response body; the authed user's saved draft\nfor this path — whatever shape the editor wrote — is attached\nas the sibling `draft` field when `get_draft=true` and a draft\nexists. The frontend pairs the two to present diff / reset /\ndiscard UI; the server never merges them.\n\nWhen `no_deployed=true` there is no deployed row at this path —\nthe response body is a best-effort stand-in synthesized from\nthe draft, and only `draft` is canonical. Callers should disable\n\"diff vs deployed\" UI in that case.\n", + "properties": { + "is_draft": { + "type": "boolean" + }, + "draft_saved_at": { + "type": "string", + "format": "date-time" + }, + "no_deployed": { + "type": "boolean" + }, + "draft": { + "type": "object", + "additionalProperties": true + }, + "other_drafts_users": { + "description": "Other workspace users (and the legacy NULL-email row, if any)\nwith a saved draft at the same path. Populated only on the\nauthed user's \"get by path\" responses for kinds the editor\nsurfaces a fork banner for (script, flow, app, raw_app).\nEmpty / omitted for kinds without that UI.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true, + "description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n" + } + } + } + } + }, + "required": [ + "is_draft" + ] + }, + "UserDraftItemKind": { + "type": "string", + "description": "Closed set of item kinds a user can autosave as a draft. Mirrors the\nPostgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.\n", + "enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + }, "OpenFlow": { "type": "object", "description": "Top-level flow definition containing metadata, configuration, and the flow structure", @@ -33354,6 +33854,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ @@ -34174,6 +34678,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -35367,6 +35875,9 @@ "items": { "type": "string" } + }, + "web_search_enabled": { + "type": "boolean" } }, "required": [ @@ -35633,9 +36144,6 @@ "tag": { "type": "string" }, - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" }, @@ -35723,6 +36231,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -35787,9 +36302,6 @@ "tag": { "type": "string" }, - "draft_only": { - "type": "boolean" - }, "envs": { "type": "array", "items": { @@ -35930,32 +36442,6 @@ "language" ] }, - "NewScriptWithDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/NewScript" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/NewScript" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - }, - "hash": { - "type": "string" - } - }, - "required": [ - "hash" - ] - } - ] - }, "ScriptHistory": { "type": "object", "properties": { @@ -37391,6 +37877,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" }, @@ -37400,6 +37893,14 @@ }, "edited_by": { "type": "string" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nvariable at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38115,6 +38616,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" } @@ -38178,8 +38686,23 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nresource at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38384,6 +38907,21 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nschedule at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -38785,6 +39323,14 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\ntrigger at the same path. Set by list endpoints when\n`include_draft_only=true` synthesizes the row from the\ndraft. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -41871,6 +42417,13 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels set on the folder. Items inside the folder inherit them, exposed as `inherited_labels` on scripts and flows and stamped into job labels at run time.\n" } }, "required": [ @@ -42369,6 +42922,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42599,6 +43159,34 @@ "type": "string" }, "default": [] + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this app — either no\ndeployed row exists at this path (draft-only) or the user has\nsaved a per-user draft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42691,6 +43279,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42780,27 +43375,6 @@ "raw_app" ] }, - "AppWithLastVersionWDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/AppWithLastVersion" - }, - { - "type": "object", - "properties": { - "draft_only": { - "type": "boolean" - }, - "draft": {}, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - }, "AppHistory": { "type": "object", "properties": { @@ -44350,7 +44924,8 @@ "enum": [ "DisableDirectDeployment", "DisableWorkspaceForking", - "RestrictDeployToDeployers" + "RestrictDeployToDeployers", + "RestrictAnonymousAppDeployment" ] }, "RuleBypasserGroups": { @@ -46030,6 +46605,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -46062,6 +46641,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 4ced5ef265..22792b38e3 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.713.1 + version: 1.723.0 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_347 + required: &ref_352 - database - readiness - properties: &ref_348 + properties: &ref_353 database: type: object description: Database health status - required: &ref_349 + required: &ref_354 - healthy - latency_ms - pool - properties: &ref_350 + properties: &ref_355 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_351 + required: &ref_356 - size - idle - max_connections - properties: &ref_352 + properties: &ref_357 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_353 + required: &ref_358 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_354 + properties: &ref_359 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_355 + required: &ref_360 - pending_jobs - running_jobs - properties: &ref_356 + properties: &ref_361 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_357 + required: &ref_362 - healthy - properties: &ref_358 + properties: &ref_363 healthy: type: boolean description: Whether the server is ready to accept requests @@ -337,7 +337,7 @@ paths: - name: id in: path required: true - schema: &ref_79 + schema: &ref_84 type: integer responses: '200': @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_299 + schema: &ref_304 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_300 + schema: &ref_305 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_308 + schema: &ref_313 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_309 + schema: &ref_314 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_310 + schema: &ref_315 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_311 + schema: &ref_316 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_397 + properties: &ref_403 email: type: string password: type: string - required: &ref_398 + required: &ref_404 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_394 + properties: &ref_400 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_395 + required: &ref_401 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_399 + properties: &ref_405 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_413 + properties: &ref_419 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_414 + required: &ref_420 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_400 + properties: &ref_406 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_401 + required: &ref_407 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_402 + properties: &ref_408 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_403 + required: &ref_409 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_404 + properties: &ref_410 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_405 + required: &ref_411 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_406 + properties: &ref_412 workspaces: type: array items: type: object - properties: &ref_408 + properties: &ref_414 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_409 + required: &ref_415 - workspace_id - username - preview - required: &ref_407 + required: &ref_413 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_410 + properties: &ref_416 reassignments: type: object additionalProperties: type: object - properties: &ref_411 + properties: &ref_417 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_412 + required: &ref_418 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_562 + items: &ref_563 type: object properties: workspace_id: @@ -1699,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_502 + properties: &ref_504 email: type: string workspaces: @@ -1774,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_503 + required: &ref_505 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1836,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_504 + properties: &ref_506 id: type: string name: @@ -1845,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_505 + required: &ref_507 - id - name responses: @@ -2021,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_470 + properties: &ref_472 super_admin: type: string enum: &ref_21 @@ -2828,11 +2828,11 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_546 name: type: string value: {} - required: &ref_546 + required: &ref_547 - name - value /settings/instance_config: @@ -2930,9 +2930,9 @@ paths: application/json: schema: type: object - required: &ref_370 + required: &ref_375 - keys - properties: &ref_371 + properties: &ref_376 keys: type: array items: @@ -3049,11 +3049,11 @@ paths: type: array items: type: object - required: &ref_368 + required: &ref_373 - workspace_id - path - error - properties: &ref_369 + properties: &ref_374 workspace_id: type: string description: Workspace ID where the secret is located @@ -4348,13 +4348,13 @@ paths: application/json: schema: type: object - required: &ref_554 + required: &ref_555 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_555 + properties: &ref_556 all_ahead_items_visible: type: boolean description: >- @@ -4375,7 +4375,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_556 + required: &ref_557 - kind - path - ahead @@ -4383,7 +4383,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_557 + properties: &ref_558 kind: type: string enum: @@ -4428,7 +4428,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_558 + required: &ref_559 - total_diffs - total_ahead - total_behind @@ -4442,7 +4442,7 @@ paths: - schedules_changed - triggers_changed - conflicts - properties: &ref_559 + properties: &ref_560 total_diffs: type: integer description: Total number of items with differences @@ -4639,12 +4639,12 @@ paths: type: array items: type: object - properties: &ref_531 + properties: &ref_532 pattern: type: string allow: type: string - required: &ref_532 + required: &ref_533 - pattern - allow secondary_storage: @@ -4777,7 +4777,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_359 + properties: &ref_364 enabled: type: boolean default: false @@ -4818,14 +4818,16 @@ paths: type: object additionalProperties: type: object - properties: &ref_378 + properties: &ref_383 resource_path: type: string models: type: array items: type: string - required: &ref_379 + web_search_enabled: + type: boolean + required: &ref_384 - resource_path - models default_model: @@ -4871,7 +4873,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_360 + properties: &ref_365 path: type: string description: Path to the error handler script or flow @@ -4888,7 +4890,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_361 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5192,7 +5194,7 @@ paths: type: array items: type: object - properties: &ref_507 + properties: &ref_509 importer_path: type: string importer_kind: @@ -5206,7 +5208,7 @@ paths: items: type: string nullable: true - required: &ref_508 + required: &ref_510 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -5264,13 +5266,13 @@ paths: type: array items: type: object - properties: &ref_509 + properties: &ref_511 imported_path: type: string count: type: integer format: int64 - required: &ref_510 + required: &ref_512 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -5293,7 +5295,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_508 workspace_id: type: string nullable: true @@ -5825,7 +5827,7 @@ paths: type: array items: type: object - properties: &ref_380 + properties: &ref_385 provider: type: string enum: *ref_51 @@ -5833,7 +5835,7 @@ paths: type: array items: type: string - required: &ref_381 + required: &ref_386 - provider - models default_model: @@ -5924,10 +5926,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_362 + oneOf: &ref_367 - type: object description: New grouped format for editing error handler - properties: &ref_363 + properties: &ref_368 path: type: string description: Path to the error handler script or flow @@ -5945,7 +5947,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_364 + properties: &ref_369 error_handler: type: string description: Path to the error handler script or flow @@ -5984,10 +5986,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_365 + oneOf: &ref_370 - type: object description: New grouped format for editing success handler - properties: &ref_366 + properties: &ref_371 path: type: string description: Path to the success handler script or flow @@ -5999,7 +6001,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_367 + properties: &ref_372 success_handler: type: string description: Path to the success handler script or flow @@ -6116,10 +6118,10 @@ paths: type: array items: type: object - required: &ref_523 + required: &ref_524 - datatable_name - schemas - properties: &ref_524 + properties: &ref_525 datatable_name: type: string schemas: @@ -6161,10 +6163,10 @@ paths: type: array items: type: object - required: &ref_525 + required: &ref_526 - datatable_name - schemas - properties: &ref_526 + properties: &ref_527 datatable_name: type: string schemas: @@ -6209,12 +6211,12 @@ paths: application/json: schema: type: object - required: &ref_527 + required: &ref_528 - datatable_name - schema_name - table_name - columns - properties: &ref_528 + properties: &ref_529 datatable_name: type: string schema_name: @@ -6975,7 +6977,7 @@ paths: type: array items: type: object - properties: &ref_396 + properties: &ref_402 email: type: string executions: @@ -7038,7 +7040,7 @@ paths: type: array items: type: object - properties: &ref_518 + properties: &ref_520 name: type: string description: @@ -7048,7 +7050,7 @@ paths: type: array items: type: object - properties: &ref_516 + properties: &ref_518 value: type: string label: @@ -7058,11 +7060,11 @@ paths: nullable: true requires_resource_path: type: boolean - required: &ref_517 + required: &ref_519 - value - label - requires_resource_path - required: &ref_519 + required: &ref_521 - name - scopes /users/tokens/create: @@ -7078,7 +7080,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_421 label: type: string expiration: @@ -7119,7 +7121,7 @@ paths: application/json: schema: type: object - properties: &ref_416 + properties: &ref_422 label: type: string expiration: @@ -7129,7 +7131,7 @@ paths: type: string workspace_id: type: string - required: &ref_417 + required: &ref_423 - impersonate_email responses: '201': @@ -7189,6 +7191,36 @@ paths: text/plain: schema: type: string + /users/tokens/update_label/{token_prefix}: + post: + summary: update label of an existing token (owner only) + operationId: updateTokenLabel + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new label (null or omitted = no label) + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + nullable: true + responses: + '200': + description: label updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -7217,7 +7249,7 @@ paths: type: array items: type: object - properties: &ref_102 + properties: &ref_108 label: type: string expiration: @@ -7241,7 +7273,7 @@ paths: type: string read_only: type: boolean - required: &ref_103 + required: &ref_109 - token_prefix - created_at - last_used_at @@ -7297,7 +7329,7 @@ paths: application/json: schema: type: object - properties: &ref_420 + properties: &ref_426 path: type: string description: The path to the variable @@ -7326,7 +7358,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_421 + required: &ref_427 - path - value - is_secret @@ -7448,7 +7480,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_428 path: type: string description: The path to the variable @@ -7504,59 +7536,168 @@ paths: in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: &ref_77 + type: boolean responses: '200': description: variable content: application/json: schema: - type: object - properties: &ref_61 - workspace_id: - type: string - path: - type: string - value: - type: string - is_secret: - type: boolean - description: - type: string - account: - type: integer - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - expires_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - edited_at: - type: string - format: date-time - edited_by: - type: string - required: &ref_62 - - workspace_id - - path - - is_secret - - extra_perms + allOf: + - type: object + properties: &ref_61 + workspace_id: + type: string + path: + type: string + value: + type: string + is_secret: + type: boolean + description: + type: string + account: + type: integer + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + expires_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + variable at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_62 + - workspace_id + - path + - is_secret + - extra_perms + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: &ref_78 + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: > + Other workspace users (and the legacy NULL-email row, + if any) + + with a saved draft at the same path. Populated only on + the + + authed user's "get by path" responses for kinds the + editor + + surfaces a fork banner for (script, flow, app, + raw_app). + + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: > + Workspace username of the draft owner. `null` + represents + + the legacy workspace-level (NULL-email) row. + Emails never + + leave the server. + required: &ref_79 + - is_draft /w/{workspace}/variables/get_value/{path}: get: summary: get variable value @@ -7662,6 +7803,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: '200': description: variable list @@ -7693,7 +7843,7 @@ paths: type: array items: type: object - properties: &ref_418 + properties: &ref_424 name: type: string value: @@ -7702,7 +7852,7 @@ paths: type: string is_custom: type: boolean - required: &ref_419 + required: &ref_425 - name - value - description @@ -7889,12 +8039,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_565 + required: &ref_566 - name - rules - bypass_groups - bypass_users - properties: &ref_566 + properties: &ref_567 name: type: string description: Unique name for the protection rule @@ -7906,10 +8056,11 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_567 + enum: &ref_568 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment bypass_groups: type: array description: Groups that can bypass this ruleset @@ -8066,11 +8217,11 @@ paths: type: array items: type: object - required: &ref_568 + required: &ref_569 - username - email - is_admin - properties: &ref_569 + properties: &ref_570 username: type: string email: @@ -8125,10 +8276,10 @@ paths: type: array items: type: object - required: &ref_570 + required: &ref_571 - username - email - properties: &ref_571 + properties: &ref_572 username: type: string email: @@ -9051,7 +9202,7 @@ paths: application/json: schema: type: object - properties: &ref_427 + properties: &ref_433 path: type: string description: The path to the resource @@ -9068,7 +9219,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_428 + required: &ref_434 - path - value - resource_type @@ -9159,7 +9310,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_435 path: type: string description: The path to the resource @@ -9230,44 +9381,123 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: resource content: application/json: schema: - type: object - properties: &ref_430 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_431 - - path - - resource_type - - is_oauth + allOf: + - type: object + properties: &ref_80 + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + account: + type: number + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + resource at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_81 + - path + - resource_type + - is_oauth + - is_linked + - is_refreshed + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/resources/get_value_interpolated/{path}: get: summary: get resource interpolated (variables and resources are fully unrolled) @@ -9438,6 +9668,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: resource list @@ -9447,49 +9686,8 @@ paths: type: array items: type: object - properties: &ref_432 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - account: - type: number - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_433 - - path - - resource_type - - is_oauth - - is_linked - - is_refreshed + properties: *ref_80 + required: *ref_81 /w/{workspace}/resources/list_search: get: summary: list resources for search @@ -9565,7 +9763,7 @@ paths: - name: name in: path required: true - schema: &ref_273 + schema: &ref_278 type: string responses: '200': @@ -9602,7 +9800,7 @@ paths: application/json: schema: type: object - properties: &ref_77 + properties: &ref_82 workspace_id: type: string name: @@ -9619,7 +9817,7 @@ paths: type: string is_fileset: type: boolean - required: &ref_78 + required: &ref_83 - name responses: '201': @@ -9698,7 +9896,7 @@ paths: application/json: schema: type: object - properties: &ref_434 + properties: &ref_436 schema: {} description: type: string @@ -9733,8 +9931,8 @@ paths: application/json: schema: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/exists/{path}: get: summary: does resource_type exists @@ -9778,8 +9976,8 @@ paths: type: array items: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/listnames: get: summary: list resource_types names @@ -10061,7 +10259,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: flow @@ -10075,7 +10273,7 @@ paths: description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: &ref_120 + properties: &ref_124 summary: type: string description: Short description of what this flow does @@ -10087,7 +10285,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_610 + properties: &ref_611 modules: type: array description: >- @@ -10098,7 +10296,7 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: &ref_82 + properties: &ref_87 id: type: string description: >- @@ -10112,14 +10310,14 @@ paths: Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: &ref_88 + oneOf: &ref_93 - type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_323 + properties: &ref_328 input_transforms: type: object description: >- @@ -10133,14 +10331,14 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_80 + oneOf: &ref_85 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: &ref_136 + properties: &ref_140 value: description: >- The static value. For resources, use @@ -10149,7 +10347,7 @@ paths: type: string enum: - static - required: &ref_137 + required: &ref_141 - type - type: object description: >- @@ -10159,7 +10357,7 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: &ref_84 + properties: &ref_89 expr: type: string description: >- @@ -10172,7 +10370,7 @@ paths: type: string enum: - javascript - required: &ref_85 + required: &ref_90 - expr - type - type: object @@ -10180,14 +10378,14 @@ paths: Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: &ref_86 + properties: &ref_91 type: type: string enum: - ai - required: &ref_87 + required: &ref_92 - type - discriminator: &ref_81 + discriminator: &ref_86 propertyName: type mapping: static: >- @@ -10297,7 +10495,7 @@ paths: - r - w - rw - required: &ref_324 + required: &ref_329 - type - content - language @@ -10307,7 +10505,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_325 + properties: &ref_330 input_transforms: type: object description: >- @@ -10321,8 +10519,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10347,7 +10545,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_326 + required: &ref_331 - type - path - input_transforms @@ -10356,7 +10554,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_327 + properties: &ref_332 input_transforms: type: object description: >- @@ -10370,8 +10568,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10381,7 +10579,7 @@ paths: type: string enum: - flow - required: &ref_328 + required: &ref_333 - type - path - input_transforms @@ -10394,7 +10592,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_329 + properties: &ref_334 modules: type: array description: >- @@ -10406,8 +10604,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: &ref_83 + properties: *ref_87 + required: &ref_88 - value - id iterator: @@ -10416,8 +10614,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -10439,11 +10637,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_330 + required: &ref_335 - modules - iterator - skip_failures @@ -10455,7 +10653,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_331 + properties: &ref_336 modules: type: array description: >- @@ -10467,8 +10665,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -10489,11 +10687,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_332 + required: &ref_337 - modules - skip_failures - type @@ -10505,7 +10703,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_333 + properties: &ref_338 branches: type: array description: >- @@ -10536,8 +10734,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -10551,13 +10749,13 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: - branchone - required: &ref_334 + required: &ref_339 - branches - default - type @@ -10568,7 +10766,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_335 + properties: &ref_340 branches: type: array description: >- @@ -10595,8 +10793,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -10609,7 +10807,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_336 + required: &ref_341 - branches - type - type: object @@ -10617,7 +10815,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_337 + properties: &ref_342 type: type: string enum: @@ -10627,7 +10825,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_338 + required: &ref_343 - type - type: object description: >- @@ -10635,7 +10833,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_339 + properties: &ref_344 input_transforms: type: object description: >- @@ -10647,22 +10845,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_341 + oneOf: &ref_346 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_595 + properties: &ref_596 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_593 + properties: &ref_594 kind: type: string description: Supported AI provider types - enum: &ref_316 + enum: &ref_321 - openai - azure_openai - anthropic @@ -10685,7 +10883,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_594 + required: &ref_595 - kind - resource - model @@ -10693,7 +10891,7 @@ paths: type: string enum: - static - required: &ref_596 + required: &ref_597 - type - value - type: object @@ -10704,16 +10902,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_342 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_347 propertyName: type mapping: static: >- @@ -10728,8 +10926,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -10743,8 +10941,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation @@ -10756,8 +10954,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. @@ -10769,8 +10967,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -10783,27 +10981,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_343 + oneOf: &ref_348 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_601 + properties: &ref_602 value: description: Conversation memory configuration - oneOf: &ref_599 + oneOf: &ref_600 - type: object description: No conversation memory/context - properties: &ref_317 + properties: &ref_322 kind: type: string enum: - 'off' - required: &ref_318 + required: &ref_323 - kind - type: object description: Automatic context management - properties: &ref_319 + properties: &ref_324 kind: type: string enum: @@ -10818,11 +11016,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_320 + required: &ref_325 - kind - type: object description: Explicit message history - properties: &ref_321 + properties: &ref_326 kind: type: string enum: @@ -10832,7 +11030,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_597 + properties: &ref_598 role: type: string enum: @@ -10841,13 +11039,13 @@ paths: - system content: type: string - required: &ref_598 + required: &ref_599 - role - content - required: &ref_322 + required: &ref_327 - kind - messages - discriminator: &ref_600 + discriminator: &ref_601 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10857,7 +11055,7 @@ paths: type: string enum: - static - required: &ref_602 + required: &ref_603 - type - value - type: object @@ -10868,16 +11066,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_344 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_349 propertyName: type mapping: static: >- @@ -10892,8 +11090,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI @@ -10914,8 +11112,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -10932,8 +11130,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -10947,8 +11145,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Float. Controls randomness/creativity of responses. @@ -10967,8 +11165,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -10990,7 +11188,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_345 + properties: &ref_350 id: type: string description: >- @@ -11008,12 +11206,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_608 + oneOf: &ref_609 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_603 + allOf: &ref_604 - type: object properties: tool_type: @@ -11027,8 +11225,8 @@ paths: step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: &ref_89 + oneOf: *ref_93 + discriminator: &ref_94 propertyName: type mapping: rawscript: '#/components/schemas/schemas-RawScript' @@ -11046,7 +11244,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_604 + properties: &ref_605 tool_type: type: string enum: @@ -11070,7 +11268,7 @@ paths: MCP server items: type: string - required: &ref_605 + required: &ref_606 - tool_type - resource_path - type: object @@ -11078,26 +11276,32 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_606 + properties: &ref_607 tool_type: type: string enum: - websearch - required: &ref_607 + required: &ref_608 - tool_type - discriminator: &ref_609 + discriminator: &ref_610 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_346 + required: &ref_351 - id - value type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. + If not set, the AI agent step runs on + the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -11111,15 +11315,15 @@ paths: description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_340 + required: &ref_345 - tools - type - input_transforms - discriminator: *ref_89 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: &ref_90 + properties: &ref_95 skip_if_stopped: type: boolean description: >- @@ -11143,13 +11347,22 @@ paths: If empty string, a default error message is used. If null or omitted, no error is raised. - required: &ref_91 + error_include_result: + type: boolean + description: >- + When stopping with an error + (error_message set), embed the stopping + step's own result inside the raised + error object (as error.result) instead + of discarding it. The top-level result + stays { error }. Defaults to false. + required: &ref_96 - expr stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -11169,8 +11382,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: >- @@ -11183,8 +11396,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -11243,8 +11456,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: >- @@ -11275,7 +11488,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_315 + properties: &ref_320 constant: type: object description: >- @@ -11316,14 +11529,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_195 + properties: &ref_199 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_196 + required: &ref_200 - expr debouncing: description: >- @@ -11360,21 +11573,21 @@ paths: description: >- Maximum number of debounces before forced execution - required: *ref_83 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -11464,7 +11677,7 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: &ref_145 + properties: &ref_149 id: type: string description: Unique identifier for the note @@ -11523,7 +11736,7 @@ paths: description: >- For group notes, the IDs of nodes contained within this group - required: &ref_146 + required: &ref_150 - id - text - color @@ -11543,7 +11756,7 @@ paths: collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: &ref_147 + properties: &ref_151 summary: type: string description: Display name for this group @@ -11569,10 +11782,10 @@ paths: color: type: string description: Color for the group in the flow editor - required: &ref_148 + required: &ref_152 - start_id - end_id - required: &ref_611 + required: &ref_612 - modules schema: type: object @@ -11586,7 +11799,7 @@ paths: description: >- The flow will be run with the permissions of the user with this email. - required: &ref_121 + required: &ref_125 - summary - value /apps/hub/list: @@ -11639,7 +11852,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app @@ -11669,7 +11882,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: raw app @@ -11699,7 +11912,7 @@ paths: - name: custom_path in: path required: true - schema: &ref_133 + schema: &ref_137 type: string responses: '200': @@ -11709,7 +11922,7 @@ paths: schema: allOf: - type: object - properties: &ref_128 + properties: &ref_132 id: type: integer workspace_id: @@ -11730,7 +11943,7 @@ paths: value: {} policy: type: object - properties: &ref_127 + properties: &ref_131 triggerables: type: object additionalProperties: @@ -11783,7 +11996,7 @@ paths: items: type: string default: [] - required: &ref_129 + required: &ref_133 - id - workspace_id - path @@ -11810,7 +12023,7 @@ paths: - name: path in: path required: true - schema: &ref_92 + schema: &ref_97 type: string responses: '200': @@ -11829,7 +12042,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script details @@ -11860,7 +12073,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script pick recorded @@ -11923,7 +12136,7 @@ paths: type: number kind: type: string - enum: &ref_93 + enum: &ref_98 - script - failure - trigger @@ -11994,7 +12207,7 @@ paths: type: string kind: type: string - enum: *ref_93 + enum: *ref_98 score: type: number required: @@ -12056,7 +12269,7 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: &ref_118 + schema: &ref_122 type: boolean - name: created_by description: >- @@ -12064,7 +12277,7 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: &ref_119 + schema: &ref_123 type: string - name: path_start description: mask to filter matching starting path @@ -12204,183 +12417,225 @@ paths: schema: type: array items: - type: object - properties: &ref_99 - workspace_id: - type: string - hash: - type: string - path: - type: string - parent_hashes: - type: array - description: > - The first element is the direct parent of the script, - the second is the parent of the first, etc - items: - type: string - summary: - type: string - description: - type: string - content: - type: string - created_by: - type: string - created_at: - type: string - format: date-time - archived: - type: boolean - schema: - type: object - deleted: - type: boolean - is_template: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - lock: - type: string - lock_error_logs: - type: string - language: - type: string - enum: &ref_94 - - python3 - - deno - - go - - bash - - powershell - - postgresql - - mysql - - bigquery - - snowflake - - mssql - - oracledb - - graphql - - nativets - - bun - - php - - rust - - ansible - - csharp - - nu - - java - - ruby - - rlang - - duckdb - - bunnative - kind: - type: string - enum: - - script - - failure - - trigger - - command - - approval - - preprocessor - starred: - type: boolean - tag: - type: string - has_draft: - type: boolean - draft_only: - type: boolean - envs: - type: array - items: - type: string - concurrent_limit: - type: integer - concurrency_time_window_s: - type: integer - concurrency_key: - type: string - debounce_key: - type: string - debounce_delay_s: - type: integer - debounce_args_to_accumulate: - type: array - items: - type: string - max_total_debouncing_time: - type: integer - max_total_debounces_amount: - type: integer - cache_ttl: - type: number - dedicated_worker: - type: boolean - ws_error_handler_muted: - type: boolean - priority: - type: integer - restart_unless_cancelled: - type: boolean - timeout: - type: integer - delete_after_secs: - type: integer - description: >- - If set, delete the job's args, result and logs after - this many seconds following job completion - visible_to_runner_only: - type: boolean - auto_kind: - type: string - codebase: - type: string - has_preprocessor: - type: boolean - on_behalf_of_email: - type: string - modules: - type: object - nullable: true - description: Additional script modules keyed by relative file path - additionalProperties: - type: object - description: An additional module file associated with a script - properties: &ref_95 - content: + allOf: + - type: object + properties: &ref_105 + workspace_id: + type: string + hash: + type: string + path: + type: string + parent_hashes: + type: array + description: > + The first element is the direct parent of the + script, the second is the parent of the first, etc + items: type: string - description: The source code content of this module - language: + summary: + type: string + description: + type: string + content: + type: string + created_by: + type: string + created_at: + type: string + format: date-time + archived: + type: boolean + schema: + type: object + deleted: + type: boolean + is_template: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + lock: + type: string + lock_error_logs: + type: string + language: + type: string + enum: &ref_99 + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - oracledb + - graphql + - nativets + - bun + - php + - rust + - ansible + - csharp + - nu + - java + - ruby + - rlang + - duckdb + - bunnative + kind: + type: string + enum: + - script + - failure + - trigger + - command + - approval + - preprocessor + starred: + type: boolean + tag: + type: string + draft_only: + type: boolean + envs: + type: array + items: type: string - enum: *ref_94 - lock: + concurrent_limit: + type: integer + concurrency_time_window_s: + type: integer + concurrency_key: + type: string + debounce_key: + type: string + debounce_delay_s: + type: integer + debounce_args_to_accumulate: + type: array + items: type: string - nullable: true - description: Lock file content for this module's dependencies - required: &ref_96 - - content - - language - labels: - type: array - items: - type: string - default: [] - required: &ref_100 - - hash - - path - - summary - - description - - content - - created_by - - created_at - - archived - - deleted - - is_template - - extra_perms - - language - - kind - - starred - - has_preprocessor + max_total_debouncing_time: + type: integer + max_total_debounces_amount: + type: integer + cache_ttl: + type: number + dedicated_worker: + type: boolean + ws_error_handler_muted: + type: boolean + priority: + type: integer + restart_unless_cancelled: + type: boolean + timeout: + type: integer + delete_after_secs: + type: integer + description: >- + If set, delete the job's args, result and logs after + this many seconds following job completion + visible_to_runner_only: + type: boolean + auto_kind: + type: string + codebase: + type: string + has_preprocessor: + type: boolean + on_behalf_of_email: + type: string + modules: + type: object + nullable: true + description: >- + Additional script modules keyed by relative file + path + additionalProperties: + type: object + description: An additional module file associated with a script + properties: &ref_101 + content: + type: string + description: The source code content of this module + language: + type: string + enum: *ref_99 + lock: + type: string + nullable: true + description: >- + Lock file content for this module's + dependencies + required: &ref_102 + - content + - language + labels: + type: array + items: + type: string + default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_106 + - hash + - path + - summary + - description + - content + - created_by + - created_at + - archived + - deleted + - is_template + - extra_perms + - language + - kind + - starred + - has_preprocessor + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: summary: list all scripts paths @@ -12401,10 +12656,12 @@ paths: type: array items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: >- + list every draft the current user has in this workspace, across all + kinds + operationId: listDrafts tags: - draft parameters: @@ -12412,37 +12669,75 @@ paths: in: path required: true schema: *ref_4 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: - - flow - - script - - app - value: {} - required: - - path - - typ - - enum responses: - '201': - description: draft created + '200': + description: the user's drafts content: - text/plain: + application/json: schema: - type: string - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + type: array + items: + type: object + properties: + kind: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. + Mirrors the + + Postgres `DRAFT_KIND` enum and the backend + `UserDraftItemKind`. + enum: &ref_100 + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github + path: + type: string + summary: + type: string + description: >- + Best-effort, read from the draft JSON's `summary` field + when the editor shape carries one. + draft_only: + type: boolean + description: >- + No deployed counterpart exists at this path — the draft + is the whole item. + created_at: + type: string + format: date-time + required: + - kind + - path + - draft_only + - created_at + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: >- + fetch a single draft's content by workspace username (or the legacy + workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -12455,21 +12750,107 @@ paths: required: true schema: type: string - enum: - - script - - flow - - app + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 + - name: username + in: query + required: false + description: >- + Workspace username of the draft owner. Omit to fetch the legacy + workspace-level (NULL email) row. + schema: + type: string responses: '200': - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: + - value + - created_at + '404': + description: no draft for that owner at that path + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: path + required: true + schema: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 + - name: path + in: path + required: true + schema: *ref_97 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: >- + Draft content to save. `null` (or omitted) signals a delete + — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: >- + Server timestamp of the client's last known sync for this + draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + '200': + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - saved + - conflict + current_timestamp: + type: string + format: date-time + required: + - status + - current_timestamp /w/{workspace}/scripts/create: post: summary: create script @@ -12507,7 +12888,7 @@ paths: application/json: schema: type: object - properties: &ref_104 + properties: &ref_392 path: type: string parent_hash: @@ -12526,7 +12907,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 kind: type: string enum: @@ -12538,8 +12919,6 @@ paths: - preprocessor tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -12611,7 +12990,7 @@ paths: type: string kind: type: string - enum: &ref_301 + enum: &ref_306 - s3object - resource - ducklake @@ -12636,8 +13015,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 labels: type: array items: @@ -12647,7 +13026,7 @@ paths: description: >- When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path. - required: &ref_105 + required: &ref_393 - path - summary - content @@ -12673,7 +13052,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -12780,19 +13159,19 @@ paths: application/json: schema: type: object - properties: &ref_385 + properties: &ref_390 workspace_id: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: type: string description: type: string content: type: string - required: &ref_386 + required: &ref_391 - workspace_id - language - content @@ -12819,7 +13198,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12847,7 +13226,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12879,7 +13258,7 @@ paths: type: array items: type: object - properties: &ref_97 + properties: &ref_103 id: type: integer archived: @@ -12892,13 +13271,13 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_id: type: string created_at: type: string format: date-time - required: &ref_98 + required: &ref_104 - workspace_id - language - created_at @@ -12921,7 +13300,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12934,8 +13313,8 @@ paths: application/json: schema: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 /w/{workspace}/scripts/archive/p/{path}: post: summary: archive script by path @@ -12950,7 +13329,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script archived @@ -12972,7 +13351,7 @@ paths: - name: hash in: path required: true - schema: &ref_101 + schema: &ref_107 type: string responses: '200': @@ -12981,8 +13360,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/h/{hash}: post: summary: delete script by hash (erase content but keep hash, require admin) @@ -12998,7 +13377,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13006,8 +13385,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/p/{path}: post: summary: delete script at a given path (require admin) @@ -13023,7 +13402,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -13085,20 +13464,63 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: script details content: application/json: schema: - type: object - properties: *ref_99 - required: *ref_100 + allOf: + - type: object + properties: *ref_105 + required: *ref_106 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/scripts/get_triggers_count/{path}: get: summary: get triggers count of script @@ -13113,7 +13535,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -13121,7 +13543,7 @@ paths: application/json: schema: type: object - properties: &ref_125 + properties: &ref_129 primary_schedule: type: object properties: @@ -13173,7 +13595,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -13183,50 +13605,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: script details - content: - application/json: - schema: - allOf: &ref_387 - - type: object - properties: *ref_104 - required: *ref_105 - - type: object - properties: - draft: - type: object - properties: *ref_104 - required: *ref_105 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. - hash: - type: string - required: - - hash + properties: *ref_108 + required: *ref_109 /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -13241,7 +13621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script history @@ -13251,12 +13631,12 @@ paths: type: array items: type: object - properties: &ref_106 + properties: &ref_110 script_hash: type: string deployment_msg: type: string - required: &ref_107 + required: &ref_111 - script_hash /w/{workspace}/scripts/list_paths_from_workspace_runnable/{path}: get: @@ -13272,7 +13652,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of script paths @@ -13294,7 +13674,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - script responses: @@ -13304,8 +13684,8 @@ paths: application/json: schema: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 /w/{workspace}/scripts/history_update/h/{hash}/p/{path}: post: summary: update history of a script @@ -13320,11 +13700,11 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Script deployment message required: true @@ -13412,7 +13792,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13436,12 +13816,12 @@ paths: - name: token in: path required: true - schema: &ref_305 + schema: &ref_310 type: string - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13463,7 +13843,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: does it exists @@ -13485,7 +13865,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: with_starred_info in: query schema: @@ -13501,8 +13881,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/raw/h/{path}: get: summary: raw script by hash @@ -13517,7 +13897,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13539,7 +13919,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13589,7 +13969,7 @@ paths: type: array items: type: object - properties: &ref_108 + properties: &ref_112 test_script_path: type: string job_id: @@ -13603,7 +13983,7 @@ paths: type: string format: date-time nullable: true - required: &ref_109 + required: &ref_113 - test_script_path /w/{workspace}/scripts/ci_test_results_batch: post: @@ -13652,8 +14032,8 @@ paths: type: array items: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -13720,7 +14100,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: description: named workspace dependency (null for default) type: string @@ -13817,7 +14197,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -13839,20 +14219,20 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: &ref_110 + schema: &ref_114 type: string format: uuid - name: tag description: Override the tag to use in: query - schema: &ref_111 + schema: &ref_115 type: string - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: &ref_112 + schema: &ref_116 type: string - name: job_id description: >- @@ -13861,7 +14241,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: &ref_113 + schema: &ref_117 type: string format: uuid - name: invisible_to_owner @@ -13900,23 +14280,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13924,7 +14304,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -13933,19 +14313,19 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: &ref_114 + schema: &ref_118 type: string - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: &ref_115 + schema: &ref_119 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: &ref_116 + schema: &ref_120 type: boolean requestBody: description: script args @@ -13975,23 +14355,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13999,7 +14379,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14008,13 +14388,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14022,12 +14402,12 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: &ref_117 + schema: &ref_121 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 responses: '200': description: job result @@ -14048,7 +14428,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14057,13 +14437,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14071,11 +14451,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14123,13 +14503,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14137,11 +14517,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14188,13 +14568,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14202,7 +14582,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14210,11 +14590,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14241,7 +14621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14250,13 +14630,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14264,11 +14644,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14310,7 +14690,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14319,13 +14699,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14333,7 +14713,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14341,11 +14721,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14391,13 +14771,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14405,11 +14785,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14463,13 +14843,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14477,7 +14857,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14485,11 +14865,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14523,23 +14903,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14547,7 +14927,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14556,17 +14936,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14602,23 +14982,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14626,7 +15006,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14635,13 +15015,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14649,11 +15029,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14688,17 +15068,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14706,7 +15086,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14715,17 +15095,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14768,17 +15148,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14786,7 +15166,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14795,13 +15175,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14809,11 +15189,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14854,6 +15234,35 @@ paths: content: application/json: schema: {} + /w/{workspace}/jobs/job_view_token/{id}: + get: + summary: mint a read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants an authenticated + workspace member read access to this job (and its flow subtree) via a + `view_token` query param or `X-View-Token` header. Only callable by a + user who can already read the job. + operationId: getJobViewToken + tags: + - job + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: the share read token + content: + text/plain: + schema: + type: string /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -14924,14 +15333,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15006,15 +15415,15 @@ paths: type: array items: allOf: - - allOf: &ref_124 + - allOf: &ref_128 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object - properties: &ref_512 + properties: &ref_514 workspace_id: type: string path: @@ -15028,7 +15437,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_511 + additionalProperties: &ref_513 type: boolean starred: type: boolean @@ -15053,7 +15462,15 @@ paths: items: type: string default: [] - required: &ref_513 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, + computed at read time. Read-only — edit them on + the folder. + required: &ref_515 - path - edited_by - edited_at @@ -15067,10 +15484,40 @@ paths: type: number - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: summary: get flow history by path @@ -15083,7 +15530,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15095,7 +15542,7 @@ paths: type: array items: type: object - properties: &ref_122 + properties: &ref_126 id: type: integer created_at: @@ -15103,7 +15550,7 @@ paths: format: date-time deployment_msg: type: string - required: &ref_123 + required: &ref_127 - id - created_at /w/{workspace}/flows/get_latest_version/{path}: @@ -15118,7 +15565,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15128,8 +15575,8 @@ paths: application/json: schema: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 /w/{workspace}/flows/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list flow paths from workspace runnable @@ -15144,7 +15591,7 @@ paths: - name: runnable_kind in: path required: true - schema: &ref_132 + schema: &ref_136 type: string enum: - script @@ -15152,7 +15599,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: match_path_start in: query schema: @@ -15188,7 +15635,7 @@ paths: content: application/json: schema: - allOf: *ref_124 + allOf: *ref_128 /w/{workspace}/flows/history_update/v/{version}: post: summary: update flow history @@ -15239,18 +15686,61 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: flow details content: application/json: schema: - allOf: *ref_124 + allOf: + - allOf: *ref_128 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/flows/deployment_status/p/{path}: get: summary: get flow deployment status @@ -15265,7 +15755,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow status @@ -15293,7 +15783,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -15301,7 +15791,7 @@ paths: application/json: schema: type: object - properties: *ref_125 + properties: *ref_129 /w/{workspace}/flows/list_tokens/{path}: get: summary: get tokens with flow scope @@ -15316,7 +15806,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -15326,8 +15816,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 /w/{workspace}/flows/toggle_workspace_error_handler/{path}: post: summary: Toggle ON and OFF the workspace error handler for a given flow @@ -15342,7 +15832,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -15360,40 +15850,6 @@ paths: text/plain: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: flow details with draft - content: - application/json: - schema: - allOf: - - allOf: *ref_124 - - type: object - properties: - draft: - allOf: *ref_124 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -15408,7 +15864,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow details @@ -15446,13 +15902,13 @@ paths: application/json: schema: allOf: - - allOf: &ref_126 + - allOf: &ref_130 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object properties: path: @@ -15485,8 +15941,6 @@ paths: - path - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -15528,7 +15982,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Partially filled flow required: true @@ -15536,7 +15990,7 @@ paths: application/json: schema: allOf: - - allOf: *ref_126 + - allOf: *ref_130 - type: object properties: deployment_message: @@ -15568,7 +16022,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: archiveFlow required: true @@ -15601,7 +16055,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -15647,14 +16101,14 @@ paths: type: array items: type: object - required: &ref_372 + required: &ref_377 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_373 + properties: &ref_378 id: type: string format: uuid @@ -15747,14 +16201,14 @@ paths: type: array items: type: object - required: &ref_374 + required: &ref_379 - id - conversation_id - message_type - content - created_at - created_seq - properties: &ref_375 + properties: &ref_380 id: type: string format: uuid @@ -15860,14 +16314,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15891,6 +16345,17 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: &ref_212 + type: boolean responses: '200': description: All raw apps @@ -15900,7 +16365,7 @@ paths: type: array items: type: object - properties: &ref_520 + properties: &ref_522 workspace_id: type: string path: @@ -15923,7 +16388,14 @@ paths: items: type: string default: [] - required: &ref_521 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_523 - workspace_id - path - summary @@ -16135,14 +16607,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -16189,7 +16661,7 @@ paths: type: array items: type: object - properties: &ref_514 + properties: &ref_516 id: type: integer workspace_id: @@ -16222,7 +16694,61 @@ paths: items: type: string default: [] - required: &ref_515 + is_draft: + type: boolean + description: > + True when the authed user has a draft for this app — + either no + + deployed row exists at this path (draft-only) or the + user has + + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: > + User-typed path the editor has staged but not yet + deployed. + + Sourced from the draft JSON's `draft_path` field (the + editor + + only writes it when the typed path differs from the + deployed + + one). Lets the home list render the meaningful name + instead of + + the autogenerated `u/{user}/draft_{uuid}` URL path. + Omitted + + when unchanged. + draft_users: + description: > + Workspace users (including the authed user, and the + legacy + + NULL-email row if any) who have a per-user draft at this + + path. Drives the home page's user-avatar circles inside + the + + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_517 - id - workspace_id - path @@ -16267,9 +16793,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16330,9 +16854,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16406,20 +16928,71 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: '200': description: app details content: application/json: schema: - type: object - properties: *ref_128 - required: *ref_129 + allOf: + - type: object + properties: *ref_132 + required: *ref_133 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/apps/get/lite/{path}: get: summary: get app lite by path @@ -16434,7 +17007,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app lite details @@ -16442,45 +17015,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: app details with draft - content: - application/json: - schema: - allOf: &ref_522 - - type: object - properties: *ref_128 - required: *ref_129 - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -16495,7 +17031,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app history @@ -16505,12 +17041,12 @@ paths: type: array items: type: object - properties: &ref_130 + properties: &ref_134 version: type: integer deployment_msg: type: string - required: &ref_131 + required: &ref_135 - version /w/{workspace}/apps/get_latest_version/{path}: get: @@ -16524,7 +17060,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - app responses: @@ -16534,8 +17070,8 @@ paths: application/json: schema: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 /w/{workspace}/apps/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list app paths from workspace runnable @@ -16550,11 +17086,11 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of app paths @@ -16578,11 +17114,11 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 - name: version in: path required: true - schema: &ref_306 + schema: &ref_311 type: integer requestBody: description: App deployment message @@ -16623,8 +17159,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps_u/public_resource/{path}: get: summary: get public resource @@ -16704,7 +17240,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app details @@ -16712,8 +17248,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/delete/{path}: delete: summary: delete app @@ -16760,7 +17296,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16776,7 +17312,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16817,7 +17353,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16836,7 +17372,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16883,7 +17419,7 @@ paths: - name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 responses: '200': description: custom path exists @@ -16914,7 +17450,7 @@ paths: type: array items: type: object - properties: &ref_134 + properties: &ref_138 s3: type: string filename: @@ -16923,7 +17459,7 @@ paths: type: string presigned: type: string - required: &ref_135 + required: &ref_139 - s3 required: - s3_objects @@ -16936,8 +17472,8 @@ paths: type: array items: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 /w/{workspace}/apps_u/execute_component/{path}: post: summary: executeComponent @@ -16952,7 +17488,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -17137,7 +17673,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17152,17 +17688,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17170,7 +17706,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17179,7 +17715,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17240,17 +17776,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17258,7 +17794,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17267,7 +17803,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17335,14 +17871,14 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_138 + oneOf: &ref_142 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 - type: object description: >- JavaScript expression evaluated at runtime. Can @@ -17351,16 +17887,16 @@ paths: 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_139 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_143 propertyName: type mapping: static: '#/components/schemas/schemas-StaticTransform' @@ -17380,8 +17916,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 use_latest_version: type: boolean responses: @@ -17407,7 +17943,7 @@ paths: - name: id in: path required: true - schema: &ref_172 + schema: &ref_176 type: string format: uuid - name: scheduled_for @@ -17426,11 +17962,11 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17438,7 +17974,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17447,7 +17983,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17521,7 +18057,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17536,23 +18072,23 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17560,7 +18096,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17569,7 +18105,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17609,7 +18145,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17622,7 +18158,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 requestBody: description: preview required: true @@ -17630,7 +18166,7 @@ paths: application/json: schema: type: object - properties: &ref_141 + properties: &ref_145 content: type: string description: The code to run @@ -17646,7 +18182,7 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 + enum: *ref_99 tag: type: string kind: @@ -17668,8 +18204,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 temp_script_refs: type: object nullable: true @@ -17679,7 +18215,7 @@ paths: local content instead of the deployed script additionalProperties: type: string - required: &ref_142 + required: &ref_146 - args responses: '201': @@ -17707,7 +18243,7 @@ paths: application/json: schema: type: object - properties: &ref_423 + properties: &ref_429 content: type: string description: The code to run @@ -17717,8 +18253,8 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 - required: &ref_424 + enum: *ref_99 + required: &ref_430 - content - args - language @@ -17742,7 +18278,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: script args required: true @@ -17750,7 +18286,7 @@ paths: application/json: schema: type: object - properties: &ref_140 + properties: &ref_144 args: type: object description: The arguments to pass to the script or flow @@ -17775,7 +18311,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 requestBody: description: script args required: true @@ -17783,7 +18319,7 @@ paths: application/json: schema: type: object - properties: *ref_140 + properties: *ref_144 responses: '200': description: script result @@ -17817,8 +18353,8 @@ paths: application/json: schema: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 responses: '200': description: job result @@ -17853,12 +18389,12 @@ paths: application/json: schema: type: object - properties: &ref_425 + properties: &ref_431 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_426 + required: &ref_432 - args responses: '201': @@ -17891,15 +18427,15 @@ paths: type: array items: type: object - properties: &ref_143 + properties: &ref_147 raw_code: type: string path: type: string language: type: string - enum: *ref_94 - required: &ref_144 + enum: *ref_99 + required: &ref_148 - raw_code - path - language @@ -17943,8 +18479,8 @@ paths: type: array items: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 entrypoint: type: string required: @@ -17984,7 +18520,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_149 + properties: &ref_153 modules: type: array description: >- @@ -17995,22 +18531,22 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -18093,8 +18629,8 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 groups: type: array description: Semantic groups of modules for organizational purposes @@ -18107,9 +18643,9 @@ paths: naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 - required: &ref_150 + properties: *ref_151 + required: *ref_152 + required: &ref_154 - modules required: - path @@ -18141,7 +18677,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -18154,7 +18690,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -18168,14 +18704,14 @@ paths: application/json: schema: type: object - properties: &ref_152 + properties: &ref_156 value: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 path: type: string args: @@ -18186,7 +18722,7 @@ paths: type: string restarted_from: type: object - properties: &ref_151 + properties: &ref_155 flow_job_id: type: string format: uuid @@ -18219,7 +18755,7 @@ paths: `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it. type: object - properties: *ref_151 + properties: *ref_155 temp_script_refs: type: object nullable: true @@ -18230,7 +18766,7 @@ paths: of the deployed script additionalProperties: type: string - required: &ref_153 + required: &ref_157 - value - content - args @@ -18266,8 +18802,8 @@ paths: application/json: schema: type: object - properties: *ref_152 - required: *ref_153 + properties: *ref_156 + required: *ref_157 responses: '200': description: job result @@ -18292,7 +18828,7 @@ paths: application/json: schema: type: object - properties: &ref_529 + properties: &ref_530 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -18313,7 +18849,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_200 + enum: &ref_204 - script - flow required: @@ -18331,11 +18867,11 @@ paths: description: Code content for inline execution language: type: string - enum: *ref_94 + enum: *ref_99 required: - source - code - required: &ref_530 + required: &ref_531 - entrypoint_function - runnable_ref responses: @@ -18361,27 +18897,27 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_156 + schema: &ref_160 type: string - name: script_path_exact description: >- @@ -18389,7 +18925,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_157 + schema: &ref_161 type: string - name: script_path_start description: >- @@ -18397,12 +18933,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_158 + schema: &ref_162 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_159 + schema: &ref_163 type: string - name: trigger_path description: >- @@ -18410,7 +18946,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_307 + schema: &ref_312 type: string - name: trigger_kind description: >- @@ -18419,34 +18955,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_188 + schema: &ref_192 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_160 + schema: &ref_164 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_161 + schema: &ref_165 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_162 + schema: &ref_166 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_170 + schema: &ref_174 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_164 + schema: &ref_168 type: boolean - name: job_kinds description: >- @@ -18454,36 +18990,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_165 + schema: &ref_169 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_166 + schema: &ref_170 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_163 + schema: &ref_167 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_167 + schema: &ref_171 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_169 + schema: &ref_173 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_171 + schema: &ref_175 type: boolean - name: tag description: >- @@ -18491,7 +19027,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_168 + schema: &ref_172 type: string - name: page description: which page to return (start at 1, default 1) @@ -18522,7 +19058,7 @@ paths: type: array items: type: object - properties: &ref_191 + properties: &ref_195 workspace_id: type: string id: @@ -18597,14 +19133,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_175 + properties: &ref_179 step: type: integer modules: type: array items: type: object - properties: &ref_154 + properties: &ref_158 type: type: string enum: @@ -18758,20 +19294,20 @@ paths: type: array items: type: boolean - required: &ref_155 + required: &ref_159 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 failure_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 - type: object properties: parent_module: @@ -18786,13 +19322,13 @@ paths: items: type: string format: uuid - required: &ref_176 + required: &ref_180 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_177 + properties: &ref_181 scheduled_for: type: string format: date-time @@ -18808,13 +19344,13 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -18835,7 +19371,7 @@ paths: type: boolean worker: type: string - required: &ref_192 + required: &ref_196 - id - running - canceled @@ -18951,14 +19487,14 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_174 + schema: &ref_178 type: string - name: worker description: >- @@ -18966,117 +19502,117 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_182 + schema: &ref_186 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_183 + schema: &ref_187 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_184 + schema: &ref_188 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_185 + schema: &ref_189 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_190 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_187 + schema: &ref_191 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: page description: which page to return (start at 1, default 1) in: query @@ -19105,6 +19641,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -19140,96 +19689,96 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19315,7 +19864,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -19343,7 +19892,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_173 + enum: &ref_177 - webhook - default_email - email @@ -19409,7 +19958,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -19456,98 +20005,111 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19585,7 +20147,7 @@ paths: type: array items: type: object - properties: &ref_189 + properties: &ref_193 workspace_id: type: string id: @@ -19662,23 +20224,23 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 raw_flow: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19703,7 +20265,7 @@ paths: type: boolean worker: type: string - required: &ref_190 + required: &ref_194 - id - created_by - duration_ms @@ -19747,7 +20309,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_178 + properties: &ref_182 id: type: string format: uuid @@ -19842,7 +20404,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19885,7 +20447,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_179 + required: &ref_183 - id - created_by - created_at @@ -19913,8 +20475,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 responses: '200': description: Successfully imported completed jobs @@ -19951,7 +20513,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_180 + properties: &ref_184 id: type: string format: uuid @@ -20041,7 +20603,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -20082,7 +20644,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_181 + required: &ref_185 - id - created_by - created_at @@ -20110,8 +20672,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 responses: '200': description: Successfully imported queued jobs @@ -20166,123 +20728,123 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -20294,7 +20856,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -20315,6 +20877,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -20349,11 +20924,11 @@ paths: schema: type: array items: - oneOf: &ref_193 + oneOf: &ref_197 - allOf: - type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 - type: object properties: type: @@ -20362,15 +20937,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_194 + discriminator: &ref_198 propertyName: type /jobs/db_clock: get: @@ -20438,7 +21013,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: no_logs in: query schema: @@ -20461,8 +21036,8 @@ paths: content: application/json: schema: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -20477,7 +21052,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: get root job id @@ -20500,7 +21075,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: remove_ansi_warnings in: query schema: @@ -20526,7 +21101,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concatenated logs of all flow steps @@ -20548,7 +21123,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: completed job logs tail @@ -20570,7 +21145,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job args @@ -20621,7 +21196,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20668,11 +21243,11 @@ paths: type: string flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -20687,7 +21262,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20760,7 +21335,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: flow debug info details @@ -20781,7 +21356,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20789,8 +21364,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -20805,7 +21380,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: suspended_job in: query schema: @@ -20842,10 +21417,10 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: get_started in: query - schema: &ref_313 + schema: &ref_318 type: boolean responses: '200': @@ -20879,7 +21454,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job timing details @@ -20912,7 +21487,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20920,8 +21495,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -20936,7 +21511,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21000,7 +21575,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21062,7 +21637,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: scheduled for timestamp @@ -21084,7 +21659,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21115,7 +21690,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21165,7 +21740,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21226,7 +21801,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21414,7 +21989,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -21422,7 +21997,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: resume_id in: path required: true @@ -21457,7 +22032,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21499,7 +22074,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21531,7 +22106,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21557,7 +22132,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: required: true content: @@ -21585,7 +22160,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21620,7 +22195,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21662,7 +22237,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21686,8 +22261,8 @@ paths: type: object properties: job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 approvers: type: array items: @@ -21762,7 +22337,7 @@ paths: application/json: schema: type: object - properties: &ref_436 + properties: &ref_438 path: type: string description: >- @@ -21855,7 +22430,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_197 + properties: &ref_201 constant: type: object description: Retry with constant delay between attempts @@ -21890,8 +22465,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 no_flow_overlap: type: boolean description: >- @@ -21944,7 +22519,7 @@ paths: type: array items: type: string - required: &ref_437 + required: &ref_439 - path - schedule - timezone @@ -21988,7 +22563,7 @@ paths: application/json: schema: type: object - properties: &ref_438 + properties: &ref_440 schedule: type: string description: >- @@ -22063,7 +22638,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 no_flow_overlap: type: boolean description: >- @@ -22119,7 +22694,7 @@ paths: type: array items: type: string - required: &ref_439 + required: &ref_441 - schedule - timezone - args @@ -22208,189 +22783,257 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: schedule deleted content: application/json: schema: - type: object - properties: &ref_198 - path: - type: string - description: >- - The unique Windmill path for this schedule. Must be of the - form `u//` or `f//`. - edited_by: - type: string - description: Username of the last person who edited this schedule - edited_at: - type: string - format: date-time - description: Timestamp of the last edit - schedule: - type: string - description: >- - Cron expression with 6 fields (seconds, minutes, hours, - day of month, month, day of week). Example '0 0 12 * * *' - for daily at noon - timezone: - type: string - description: >- - IANA timezone for the schedule (e.g., 'UTC', - 'Europe/Paris', 'America/New_York') - enabled: - type: boolean - description: >- - Whether the schedule is currently active and will trigger - jobs - script_path: - type: string - description: Path to the script or flow to execute when triggered - is_flow: - type: boolean - description: >- - True if script_path points to a flow, false if it points - to a script - args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - extra_perms: - type: object - additionalProperties: - type: boolean - description: Additional permissions for this schedule - email: - type: string - description: >- - Email of the user who owns this schedule, used for - permissioned_as - permissioned_as: - type: string - description: >- - The user or group this schedule runs as (e.g., 'u/admin' - or 'g/mygroup') - error: - type: string - nullable: true - description: Last error message if the schedule failed to trigger - on_failure: - type: string - nullable: true - description: >- - Path to a script or flow to run when the scheduled job - fails - on_failure_times: - type: number - nullable: true - description: >- - Number of consecutive failures before the on_failure - handler is triggered (default 1) - on_failure_exact: - type: boolean - nullable: true - description: >- - If true, trigger on_failure handler only on exactly N - failures, not on every failure after N - on_failure_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_recovery: - type: string - nullable: true - description: >- - Path to a script or flow to run when the schedule recovers - after failures - on_recovery_times: - type: number - nullable: true - description: >- - Number of consecutive successes before the on_recovery - handler is triggered (default 1) - on_recovery_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_success: - type: string - nullable: true - description: >- - Path to a script or flow to run after each successful - execution - on_success_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - ws_error_handler_muted: - type: boolean - description: >- - If true, the workspace-level error handler will not be - triggered for this schedule's failures - retry: - nullable: true - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - summary: - type: string - nullable: true - description: Short summary describing the purpose of this schedule - description: - type: string - nullable: true - description: Detailed description of what this schedule does - no_flow_overlap: - type: boolean - description: >- - If true, skip this schedule's execution if the previous - run is still in progress (prevents concurrent runs) - tag: - type: string - nullable: true - description: Worker tag to route jobs to specific worker groups - paused_until: - type: string - format: date-time - nullable: true - description: >- - ISO 8601 datetime until which the schedule is paused. - Schedule resumes automatically after this time - cron_version: - type: string - nullable: true - description: >- - Cron parser version. Use 'v2' for extended syntax with - additional features - dynamic_skip: - type: string - nullable: true - description: >- - Path to a script that validates scheduled datetimes. - Receives scheduled_for datetime and returns boolean to - skip (true) or run (false) - labels: - type: array - items: - type: string - default: [] - required: &ref_199 - - path - - edited_by - - edited_at - - schedule - - script_path - - timezone - - extra_perms - - is_flow - - enabled - - email - - permissioned_as + allOf: + - type: object + properties: &ref_202 + path: + type: string + description: >- + The unique Windmill path for this schedule. Must be of + the form `u//` or `f//`. + edited_by: + type: string + description: Username of the last person who edited this schedule + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + schedule: + type: string + description: >- + Cron expression with 6 fields (seconds, minutes, + hours, day of month, month, day of week). Example '0 0 + 12 * * *' for daily at noon + timezone: + type: string + description: >- + IANA timezone for the schedule (e.g., 'UTC', + 'Europe/Paris', 'America/New_York') + enabled: + type: boolean + description: >- + Whether the schedule is currently active and will + trigger jobs + script_path: + type: string + description: Path to the script or flow to execute when triggered + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + extra_perms: + type: object + additionalProperties: + type: boolean + description: Additional permissions for this schedule + email: + type: string + description: >- + Email of the user who owns this schedule, used for + permissioned_as + permissioned_as: + type: string + description: >- + The user or group this schedule runs as (e.g., + 'u/admin' or 'g/mygroup') + error: + type: string + nullable: true + description: Last error message if the schedule failed to trigger + on_failure: + type: string + nullable: true + description: >- + Path to a script or flow to run when the scheduled job + fails + on_failure_times: + type: number + nullable: true + description: >- + Number of consecutive failures before the on_failure + handler is triggered (default 1) + on_failure_exact: + type: boolean + nullable: true + description: >- + If true, trigger on_failure handler only on exactly N + failures, not on every failure after N + on_failure_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_recovery: + type: string + nullable: true + description: >- + Path to a script or flow to run when the schedule + recovers after failures + on_recovery_times: + type: number + nullable: true + description: >- + Number of consecutive successes before the on_recovery + handler is triggered (default 1) + on_recovery_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_success: + type: string + nullable: true + description: >- + Path to a script or flow to run after each successful + execution + on_success_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + ws_error_handler_muted: + type: boolean + description: >- + If true, the workspace-level error handler will not be + triggered for this schedule's failures + retry: + nullable: true + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + summary: + type: string + nullable: true + description: Short summary describing the purpose of this schedule + description: + type: string + nullable: true + description: Detailed description of what this schedule does + no_flow_overlap: + type: boolean + description: >- + If true, skip this schedule's execution if the + previous run is still in progress (prevents concurrent + runs) + tag: + type: string + nullable: true + description: Worker tag to route jobs to specific worker groups + paused_until: + type: string + format: date-time + nullable: true + description: >- + ISO 8601 datetime until which the schedule is paused. + Schedule resumes automatically after this time + cron_version: + type: string + nullable: true + description: >- + Cron parser version. Use 'v2' for extended syntax with + additional features + dynamic_skip: + type: string + nullable: true + description: >- + Path to a script that validates scheduled datetimes. + Receives scheduled_for datetime and returns boolean to + skip (true) or run (false) + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + schedule at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_203 + - path + - edited_by + - edited_at + - schedule + - script_path + - timezone + - extra_perms + - is_flow + - enabled + - email + - permissioned_as + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/schedules/exists/{path}: get: summary: does schedule exists @@ -22438,7 +23081,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: path description: filter by path (script path) in: query @@ -22482,6 +23125,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: schedule list @@ -22491,8 +23143,8 @@ paths: type: array items: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -22520,10 +23172,10 @@ paths: schema: type: array items: - allOf: &ref_435 + allOf: &ref_437 - type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 - type: object properties: jobs: @@ -22601,10 +23253,10 @@ paths: application/json: schema: type: object - properties: &ref_201 + properties: &ref_205 info: type: object - properties: &ref_445 + properties: &ref_447 title: type: string version: @@ -22633,28 +23285,28 @@ paths: type: string required: - name - required: &ref_446 + required: &ref_448 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_440 + enum: &ref_442 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_441 + properties: &ref_443 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_442 + required: &ref_444 - folder_regex - path_regex - route_path_regex @@ -22662,7 +23314,7 @@ paths: type: array items: type: object - properties: &ref_443 + properties: &ref_445 user_or_folder_regex: type: string enum: @@ -22675,8 +23327,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_200 - required: &ref_444 + enum: *ref_204 + required: &ref_446 - user_or_folder_regex - user_or_folder_regex_value - path @@ -22705,7 +23357,7 @@ paths: application/json: schema: type: object - properties: *ref_201 + properties: *ref_205 responses: '200': description: Downloaded OpenAPI spec @@ -22734,7 +23386,7 @@ paths: type: array items: type: object - properties: &ref_202 + properties: &ref_206 path: type: string description: >- @@ -22788,7 +23440,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_204 + enum: &ref_208 - get - post - put @@ -22810,7 +23462,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_205 + enum: &ref_209 - sync - async - sync_sse @@ -22820,7 +23472,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_206 + enum: &ref_210 - none - windmill - api_key @@ -22838,7 +23490,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_207 + enum: &ref_211 - enabled - disabled - suspended @@ -22859,7 +23511,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -22875,7 +23527,7 @@ paths: type: array items: type: string - required: &ref_203 + required: &ref_207 - path - script_path - route_path @@ -22908,8 +23560,8 @@ paths: application/json: schema: type: object - properties: *ref_202 - required: *ref_203 + properties: *ref_206 + required: *ref_207 responses: '201': description: http trigger created @@ -22939,7 +23591,7 @@ paths: application/json: schema: type: object - properties: &ref_447 + properties: &ref_449 path: type: string description: >- @@ -22999,7 +23651,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_204 + enum: *ref_208 is_async: type: boolean description: Deprecated, use request_type instead @@ -23009,14 +23661,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_205 + enum: *ref_209 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_206 + enum: *ref_210 is_static_website: type: boolean description: >- @@ -23040,7 +23692,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23056,7 +23708,7 @@ paths: type: array items: type: string - required: &ref_448 + required: &ref_450 - path - script_path - is_flow @@ -23108,167 +23760,239 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: http trigger deleted content: application/json: schema: - allOf: &ref_208 - - type: object + allOf: + - allOf: &ref_213 + - type: object + properties: &ref_219 + path: + type: string + description: >- + The unique Windmill path for this trigger. Must be + of the form `u//` or + `f//`. This is the trigger object + path, not the HTTP route path. + script_path: + type: string + description: >- + Path to the script or flow to execute when + triggered + permissioned_as: + type: string + description: >- + The user or group this trigger runs as + (permissioned_as) + extra_perms: + type: object + description: Additional permissions for this trigger + additionalProperties: + type: boolean + workspace_id: + type: string + description: The workspace this trigger belongs to + edited_by: + type: string + description: >- + Username of the last person who edited this + trigger + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + mode: + description: job trigger mode + type: string + enum: *ref_211 + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + trigger at the same path. Set by list endpoints + when + + `include_draft_only=true` synthesizes the row from + the + + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at + this path + + (over a deployed row or a synthesized draft-only + row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_220 + - path + - script_path + - permissioned_as + - extra_perms + - workspace_id + - edited_by + - edited_at + - is_flow + - mode + type: object properties: &ref_214 - path: + route_path: type: string description: >- - The unique Windmill path for this trigger. Must be of - the form `u//` or `f//`. - This is the trigger object path, not the HTTP route - path. - script_path: - type: string - description: Path to the script or flow to execute when triggered - permissioned_as: - type: string - description: >- - The user or group this trigger runs as - (permissioned_as) - extra_perms: + The URL route path that will trigger this endpoint + (e.g., 'api/myendpoint'). Must NOT start with a /. + static_asset_config: type: object - description: Additional permissions for this trigger - additionalProperties: - type: boolean - workspace_id: + nullable: true + description: >- + Configuration for serving static assets (s3 bucket, + storage path, filename) + properties: + s3: + type: string + description: S3 bucket path for static assets + storage: + type: string + description: Storage path for static assets + filename: + type: string + description: Filename for the static asset + required: + - s3 + http_method: + description: >- + HTTP method (get, post, put, delete, patch) that + triggers this endpoint type: string - description: The workspace this trigger belongs to - edited_by: + enum: *ref_208 + authentication_resource_path: type: string - description: Username of the last person who edited this trigger - edited_at: + nullable: true + description: >- + Path to the resource containing authentication + configuration (for api_key, basic_http, custom_script, + signature methods) + summary: type: string - format: date-time - description: Timestamp of the last edit - is_flow: + nullable: true + description: Short summary describing the purpose of this trigger + description: + type: string + nullable: true + description: Detailed description of what this trigger does + request_type: + description: >- + How the request is handled - 'sync' waits for result, + 'async' returns job ID immediately, 'sync_sse' streams + results via Server-Sent Events + type: string + enum: *ref_209 + authentication_method: + description: >- + How requests are authenticated - 'none' (public), + 'windmill' (Windmill token), 'api_key', 'basic_http', + 'custom_script', 'signature' + type: string + enum: *ref_210 + is_static_website: type: boolean description: >- - True if script_path points to a flow, false if it - points to a script - mode: - description: job trigger mode + If true, serves static files from S3/storage instead + of running a script + workspaced_route: + type: boolean + description: >- + If true, the route includes the workspace ID in the + path + wrap_body: + type: boolean + description: If true, wraps the request body in a 'body' parameter + raw_string: + type: boolean + description: >- + If true, passes the request body as a raw string + instead of parsing as JSON + error_handler_path: type: string - enum: *ref_207 - labels: - type: array - items: - type: string - default: [] + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 required: &ref_215 - - path - - script_path - - permissioned_as - - extra_perms - - workspace_id - - edited_by - - edited_at - - is_flow - - mode - type: object - properties: &ref_209 - route_path: - type: string - description: >- - The URL route path that will trigger this endpoint (e.g., - 'api/myendpoint'). Must NOT start with a /. - static_asset_config: - type: object - nullable: true - description: >- - Configuration for serving static assets (s3 bucket, - storage path, filename) - properties: - s3: - type: string - description: S3 bucket path for static assets - storage: - type: string - description: Storage path for static assets - filename: - type: string - description: Filename for the static asset - required: - - s3 - http_method: - description: >- - HTTP method (get, post, put, delete, patch) that triggers - this endpoint - type: string - enum: *ref_204 - authentication_resource_path: - type: string - nullable: true - description: >- - Path to the resource containing authentication - configuration (for api_key, basic_http, custom_script, - signature methods) - summary: - type: string - nullable: true - description: Short summary describing the purpose of this trigger - description: - type: string - nullable: true - description: Detailed description of what this trigger does - request_type: - description: >- - How the request is handled - 'sync' waits for result, - 'async' returns job ID immediately, 'sync_sse' streams - results via Server-Sent Events - type: string - enum: *ref_205 - authentication_method: - description: >- - How requests are authenticated - 'none' (public), - 'windmill' (Windmill token), 'api_key', 'basic_http', - 'custom_script', 'signature' - type: string - enum: *ref_206 - is_static_website: - type: boolean - description: >- - If true, serves static files from S3/storage instead of - running a script - workspaced_route: - type: boolean - description: If true, the route includes the workspace ID in the path - wrap_body: - type: boolean - description: If true, wraps the request body in a 'body' parameter - raw_string: - type: boolean - description: >- - If true, passes the request body as a raw string instead - of parsing as JSON - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_210 - - route_path - - request_type - - authentication_method - - http_method - - is_static_website - - workspaced_route - - wrap_body - - raw_string + - route_path + - request_type + - authentication_method + - http_method + - is_static_website + - workspaced_route + - wrap_body + - raw_string + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/http_triggers/list: get: summary: list http triggers @@ -23307,6 +24031,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: http trigger list @@ -23315,10 +24049,10 @@ paths: schema: type: array items: - allOf: *ref_208 + allOf: *ref_213 type: object - properties: *ref_209 - required: *ref_210 + properties: *ref_214 + required: *ref_215 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -23364,7 +24098,7 @@ paths: type: string http_method: type: string - enum: *ref_204 + enum: *ref_208 trigger_path: type: string workspaced_route: @@ -23404,7 +24138,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -23437,7 +24171,7 @@ paths: application/json: schema: type: object - properties: &ref_449 + properties: &ref_451 path: type: string description: >- @@ -23462,7 +24196,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 filters: type: array description: >- @@ -23493,7 +24227,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_211 + anyOf: &ref_216 - type: object properties: raw_message: @@ -23536,7 +24270,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_212 + properties: &ref_217 interval_secs: type: integer minimum: 1 @@ -23553,7 +24287,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_213 + required: &ref_218 - interval_secs - message error_handler_path: @@ -23566,7 +24300,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23582,7 +24316,7 @@ paths: type: array items: type: string - required: &ref_450 + required: &ref_452 - path - script_path - url @@ -23619,7 +24353,7 @@ paths: application/json: schema: type: object - properties: &ref_451 + properties: &ref_453 url: type: string description: >- @@ -23671,7 +24405,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_211 + anyOf: *ref_216 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -23689,8 +24423,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_212 - required: *ref_213 + properties: *ref_217 + required: *ref_218 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23701,7 +24435,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23717,7 +24451,7 @@ paths: type: array items: type: string - required: &ref_452 + required: &ref_454 - path - script_path - url @@ -23769,103 +24503,149 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: websocket trigger deleted content: application/json: schema: - allOf: &ref_216 + allOf: + - allOf: &ref_221 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_222 + url: + type: string + description: >- + The WebSocket URL to connect to (can be a static URL + or computed by a runnable) + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + filters: + type: array + description: >- + Array of key-value filters to match incoming messages + (only matching messages trigger the script) + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + initial_messages: + type: array + nullable: true + description: >- + Messages to send immediately after connecting (can be + raw strings or computed by runnables) + items: + anyOf: *ref_216 + url_runnable_args: + description: The arguments to pass to the script or flow + nullable: true + type: object + additionalProperties: true + can_return_message: + type: boolean + description: >- + If true, the script can return a message to send back + through the WebSocket + can_return_error_result: + type: boolean + description: >- + If true, error results are sent back through the + WebSocket + heartbeat: + nullable: true + description: Optional periodic heartbeat message configuration + type: object + properties: *ref_217 + required: *ref_218 + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_223 + - url + - filters + - can_return_message + - can_return_error_result - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_217 - url: - type: string - description: >- - The WebSocket URL to connect to (can be a static URL or - computed by a runnable) - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - filters: - type: array - description: >- - Array of key-value filters to match incoming messages - (only matching messages trigger the script) - items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - initial_messages: - type: array - nullable: true - description: >- - Messages to send immediately after connecting (can be raw - strings or computed by runnables) - items: - anyOf: *ref_211 - url_runnable_args: - description: The arguments to pass to the script or flow - nullable: true - type: object - additionalProperties: true - can_return_message: - type: boolean - description: >- - If true, the script can return a message to send back - through the WebSocket - can_return_error_result: - type: boolean - description: If true, error results are sent back through the WebSocket - heartbeat: - nullable: true - description: Optional periodic heartbeat message configuration - type: object - properties: *ref_212 - required: *ref_213 - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_218 - - url - - filters - - can_return_message - - can_return_error_result + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/websocket_triggers/list: get: summary: list websocket triggers @@ -23904,6 +24684,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: websocket trigger list @@ -23912,10 +24702,10 @@ paths: schema: type: array items: - allOf: *ref_216 + allOf: *ref_221 type: object - properties: *ref_217 - required: *ref_218 + properties: *ref_222 + required: *ref_223 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -23964,7 +24754,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24034,7 +24824,7 @@ paths: application/json: schema: type: object - properties: &ref_484 + properties: &ref_486 path: type: string description: >- @@ -24103,7 +24893,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24114,7 +24904,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24130,7 +24920,7 @@ paths: type: array items: type: string - required: &ref_485 + required: &ref_487 - path - script_path - is_flow @@ -24167,7 +24957,7 @@ paths: application/json: schema: type: object - properties: &ref_486 + properties: &ref_488 kafka_resource_path: type: string description: >- @@ -24243,7 +25033,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24259,7 +25049,7 @@ paths: type: array items: type: string - required: &ref_487 + required: &ref_489 - path - script_path - kafka_resource_path @@ -24311,98 +25101,143 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: kafka trigger deleted content: application/json: schema: - allOf: &ref_219 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_220 - kafka_resource_path: - type: string - description: >- - Path to the Kafka resource containing connection - configuration - group_id: - type: string - description: Kafka consumer group ID for this trigger - topics: - type: array - items: - type: string - description: Array of Kafka topic names to subscribe to - filters: - type: array - items: - type: object - properties: - key: + allOf: + - allOf: &ref_224 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_225 + kafka_resource_path: + type: string + description: >- + Path to the Kafka resource containing connection + configuration + group_id: + type: string + description: Kafka consumer group ID for this trigger + topics: + type: array + items: type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - auto_offset_reset: - type: string - enum: - - latest - - earliest - default: latest - description: >- - Initial offset behavior when consumer group has no - committed offset. 'latest' starts from new messages only, - 'earliest' starts from the beginning. - auto_commit: - type: boolean - default: true - description: >- - When true (default), offsets are committed automatically - after receiving each message. When false, you must - manually commit offsets using the commit_offsets endpoint. - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_221 - - kafka_resource_path - - group_id - - topics - - filters + description: Array of Kafka topic names to subscribe to + filters: + type: array + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + auto_offset_reset: + type: string + enum: + - latest + - earliest + default: latest + description: >- + Initial offset behavior when consumer group has no + committed offset. 'latest' starts from new messages + only, 'earliest' starts from the beginning. + auto_commit: + type: boolean + default: true + description: >- + When true (default), offsets are committed + automatically after receiving each message. When + false, you must manually commit offsets using the + commit_offsets endpoint. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_226 + - kafka_resource_path + - group_id + - topics + - filters + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/kafka_triggers/list: get: summary: list kafka triggers @@ -24441,6 +25276,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: kafka trigger list @@ -24449,10 +25294,10 @@ paths: schema: type: array items: - allOf: *ref_219 + allOf: *ref_224 type: object - properties: *ref_220 - required: *ref_221 + properties: *ref_225 + required: *ref_226 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -24501,7 +25346,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24620,7 +25465,7 @@ paths: application/json: schema: type: object - properties: &ref_488 + properties: &ref_490 path: type: string description: >- @@ -24663,7 +25508,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24674,7 +25519,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24690,7 +25535,7 @@ paths: type: array items: type: string - required: &ref_489 + required: &ref_491 - path - script_path - is_flow @@ -24726,7 +25571,7 @@ paths: application/json: schema: type: object - properties: &ref_490 + properties: &ref_492 nats_resource_path: type: string description: >- @@ -24776,7 +25621,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24792,7 +25637,7 @@ paths: type: array items: type: string - required: &ref_491 + required: &ref_493 - path - script_path - nats_resource_path @@ -24843,72 +25688,117 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: nats trigger deleted content: application/json: schema: - allOf: &ref_222 + allOf: + - allOf: &ref_227 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_228 + nats_resource_path: + type: string + description: >- + Path to the NATS resource containing connection + configuration + use_jetstream: + type: boolean + description: >- + If true, uses NATS JetStream for durable message + delivery + stream_name: + type: string + nullable: true + description: >- + JetStream stream name (required when use_jetstream is + true) + consumer_name: + type: string + nullable: true + description: >- + JetStream consumer name (required when use_jetstream + is true) + subjects: + type: array + items: + type: string + description: Array of NATS subjects to subscribe to + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_229 + - nats_resource_path + - use_jetstream + - subjects - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_223 - nats_resource_path: - type: string - description: >- - Path to the NATS resource containing connection - configuration - use_jetstream: - type: boolean - description: If true, uses NATS JetStream for durable message delivery - stream_name: - type: string - nullable: true - description: >- - JetStream stream name (required when use_jetstream is - true) - consumer_name: - type: string - nullable: true - description: >- - JetStream consumer name (required when use_jetstream is - true) - subjects: - type: array - items: - type: string - description: Array of NATS subjects to subscribe to - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_224 - - nats_resource_path - - use_jetstream - - subjects + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/nats_triggers/list: get: summary: list nats triggers @@ -24947,6 +25837,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: nats trigger list @@ -24955,10 +25855,10 @@ paths: schema: type: array items: - allOf: *ref_222 + allOf: *ref_227 type: object - properties: *ref_223 - required: *ref_224 + properties: *ref_228 + required: *ref_229 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -25007,7 +25907,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25070,7 +25970,7 @@ paths: application/json: schema: type: object - properties: &ref_471 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25079,7 +25979,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_225 + enum: &ref_230 - oidc - credentials aws_resource_path: @@ -25114,7 +26014,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25125,7 +26025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25141,7 +26041,7 @@ paths: type: array items: type: string - required: &ref_472 + required: &ref_474 - queue_url - aws_resource_path - path @@ -25177,7 +26077,7 @@ paths: application/json: schema: type: object - properties: &ref_473 + properties: &ref_475 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25186,7 +26086,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_225 + enum: *ref_230 aws_resource_path: type: string description: >- @@ -25219,7 +26119,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25230,7 +26130,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25246,7 +26146,7 @@ paths: type: array items: type: string - required: &ref_474 + required: &ref_476 - queue_url - aws_resource_path - path @@ -25298,69 +26198,112 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: sqs trigger deleted content: application/json: schema: - allOf: &ref_226 + allOf: + - allOf: &ref_231 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_232 + queue_url: + type: string + description: The full URL of the AWS SQS queue to poll for messages + aws_auth_resource_type: + description: >- + Authentication type - 'credentials' for access + key/secret, 'oidc' for OpenID Connect + type: string + enum: *ref_230 + aws_resource_path: + type: string + description: >- + Path to the AWS resource containing credentials or + OIDC configuration + message_attributes: + type: array + nullable: true + items: + type: string + description: >- + Array of SQS message attribute names to include with + each message + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_233 + - queue_url + - aws_resource_path + - aws_auth_resource_type - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_227 - queue_url: - type: string - description: The full URL of the AWS SQS queue to poll for messages - aws_auth_resource_type: - description: >- - Authentication type - 'credentials' for access key/secret, - 'oidc' for OpenID Connect - type: string - enum: *ref_225 - aws_resource_path: - type: string - description: >- - Path to the AWS resource containing credentials or OIDC - configuration - message_attributes: - type: array - nullable: true - items: - type: string - description: >- - Array of SQS message attribute names to include with each - message - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_228 - - queue_url - - aws_resource_path - - aws_auth_resource_type + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/sqs_triggers/list: get: summary: list sqs triggers @@ -25399,6 +26342,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: sqs trigger list @@ -25407,10 +26360,10 @@ paths: schema: type: array items: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -25459,7 +26412,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25524,17 +26477,17 @@ paths: type: array items: type: object - properties: &ref_576 + properties: &ref_577 service_name: type: string - enum: &ref_229 + enum: &ref_234 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_230 + properties: &ref_235 client_id: type: string description: The OAuth client ID for the workspace @@ -25549,7 +26502,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_231 + required: &ref_236 - client_id - client_secret - base_url @@ -25558,7 +26511,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_577 + required: &ref_578 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -25576,7 +26529,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: integration exists @@ -25600,7 +26553,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger service required: true @@ -25608,8 +26561,8 @@ paths: application/json: schema: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 responses: '201': description: native trigger service created @@ -25633,7 +26586,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25641,10 +26594,10 @@ paths: application/json: schema: type: object - properties: &ref_232 + properties: &ref_237 redirect_uri: type: string - required: &ref_233 + required: &ref_238 - redirect_uri responses: '200': @@ -25669,7 +26622,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: whether instance sharing is available @@ -25693,7 +26646,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25701,8 +26654,8 @@ paths: application/json: schema: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 responses: '200': description: authorization URL using instance credentials @@ -25726,7 +26679,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: native trigger service deleted @@ -25750,7 +26703,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: OAuth callback data required: true @@ -25799,7 +26752,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger configuration required: true @@ -25808,7 +26761,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_234 + properties: &ref_239 script_path: type: string description: The path to the script or flow that will be triggered @@ -25825,7 +26778,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_235 + required: &ref_240 - script_path - is_flow - service_config @@ -25837,13 +26790,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_579 + properties: &ref_580 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_580 + required: &ref_581 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -25866,7 +26819,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25881,8 +26834,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 responses: '200': description: native trigger updated @@ -25911,7 +26864,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25928,7 +26881,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_574 + properties: &ref_575 external_id: type: string description: The unique identifier from the external service @@ -25937,7 +26890,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -25964,7 +26917,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_575 + required: &ref_576 - external_id - workspace_id - service_name @@ -25993,7 +26946,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26024,7 +26977,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: page description: which page to return (start at 1, default 1) in: query @@ -26049,6 +27002,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: native triggers list @@ -26059,7 +27022,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_572 + properties: &ref_573 external_id: type: string description: The unique identifier from the external service @@ -26068,7 +27031,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -26091,7 +27054,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_573 + required: &ref_574 - external_id - workspace_id - service_name @@ -26115,7 +27078,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26145,7 +27108,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: sync completed successfully @@ -26170,7 +27133,7 @@ paths: type: array items: type: object - properties: &ref_581 + properties: &ref_582 id: type: string name: @@ -26181,7 +27144,7 @@ paths: type: string path: type: string - required: &ref_582 + required: &ref_583 - id - name - path @@ -26206,7 +27169,7 @@ paths: type: array items: type: object - properties: &ref_583 + properties: &ref_584 id: type: string summary: @@ -26214,7 +27177,7 @@ paths: primary: type: boolean default: false - required: &ref_584 + required: &ref_585 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -26257,12 +27220,12 @@ paths: application/json: schema: type: object - properties: &ref_587 + properties: &ref_588 files: type: array items: type: object - properties: &ref_585 + properties: &ref_586 id: type: string name: @@ -26272,13 +27235,13 @@ paths: is_folder: type: boolean default: false - required: &ref_586 + required: &ref_587 - id - name - mime_type next_page_token: type: string - required: &ref_588 + required: &ref_589 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -26301,12 +27264,12 @@ paths: type: array items: type: object - properties: &ref_589 + properties: &ref_590 id: type: string name: type: string - required: &ref_590 + required: &ref_591 - id - name /w/{workspace}/native_triggers/github/repos: @@ -26330,7 +27293,7 @@ paths: type: array items: type: object - properties: &ref_591 + properties: &ref_592 full_name: type: string name: @@ -26339,7 +27302,7 @@ paths: type: string private: type: boolean - required: &ref_592 + required: &ref_593 - full_name - name - owner @@ -26356,7 +27319,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: workspace_id in: path required: true @@ -26405,7 +27368,7 @@ paths: application/json: schema: type: object - properties: &ref_454 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -26415,16 +27378,16 @@ paths: type: array items: type: object - properties: &ref_236 + properties: &ref_241 qos: type: string - enum: &ref_453 + enum: &ref_455 - qos0 - qos1 - qos2 topic: type: string - required: &ref_237 + required: &ref_242 - qos - topic description: >- @@ -26438,7 +27401,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_238 + properties: &ref_243 clean_session: type: boolean v5_config: @@ -26447,7 +27410,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_239 + properties: &ref_244 clean_start: type: boolean topic_alias_maximum: @@ -26458,7 +27421,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_240 + enum: &ref_245 - v3 - v5 path: @@ -26480,7 +27443,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26491,7 +27454,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26507,7 +27470,7 @@ paths: type: array items: type: string - required: &ref_455 + required: &ref_457 - path - script_path - is_flow @@ -26542,7 +27505,7 @@ paths: application/json: schema: type: object - properties: &ref_456 + properties: &ref_458 mqtt_resource_path: type: string description: >- @@ -26552,8 +27515,8 @@ paths: type: array items: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -26565,19 +27528,19 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_238 + properties: *ref_243 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_239 + properties: *ref_244 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_240 + enum: *ref_245 path: type: string description: >- @@ -26597,7 +27560,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26608,7 +27571,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26624,7 +27587,7 @@ paths: type: array items: type: string - required: &ref_457 + required: &ref_459 - path - script_path - is_flow @@ -26675,81 +27638,124 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: mqtt trigger deleted content: application/json: schema: - allOf: &ref_241 + allOf: + - allOf: &ref_246 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_247 + mqtt_resource_path: + type: string + description: >- + Path to the MQTT resource containing broker connection + configuration + subscribe_topics: + type: array + items: + type: object + properties: *ref_241 + required: *ref_242 + description: >- + Array of MQTT topics to subscribe to, each with topic + name and QoS level + v3_config: + nullable: true + description: MQTT v3 specific configuration (clean_session) + type: object + properties: *ref_243 + v5_config: + nullable: true + description: >- + MQTT v5 specific configuration (clean_start, + topic_alias_maximum, session_expiry_interval) + type: object + properties: *ref_244 + client_id: + type: string + nullable: true + description: MQTT client ID for this connection + client_version: + nullable: true + description: MQTT protocol version ('v3' or 'v5') + type: string + enum: *ref_245 + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_248 + - subscribe_topics + - mqtt_resource_path - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_242 - mqtt_resource_path: - type: string - description: >- - Path to the MQTT resource containing broker connection - configuration - subscribe_topics: - type: array - items: - type: object - properties: *ref_236 - required: *ref_237 - description: >- - Array of MQTT topics to subscribe to, each with topic name - and QoS level - v3_config: - nullable: true - description: MQTT v3 specific configuration (clean_session) - type: object - properties: *ref_238 - v5_config: - nullable: true - description: >- - MQTT v5 specific configuration (clean_start, - topic_alias_maximum, session_expiry_interval) - type: object - properties: *ref_239 - client_id: - type: string - nullable: true - description: MQTT client ID for this connection - client_version: - nullable: true - description: MQTT protocol version ('v3' or 'v5') - type: string - enum: *ref_240 - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_243 - - subscribe_topics - - mqtt_resource_path + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/mqtt_triggers/list: get: summary: list mqtt triggers @@ -26788,6 +27794,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: mqtt trigger list @@ -26796,10 +27812,10 @@ paths: schema: type: array items: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -26848,7 +27864,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -26912,7 +27928,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_244 + properties: &ref_249 gcp_resource_path: type: string description: >- @@ -26920,7 +27936,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_249 + enum: &ref_254 - existing - create_update description: >- @@ -26938,7 +27954,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_246 + enum: &ref_251 - push - pull description: >- @@ -26949,7 +27965,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_247 + properties: &ref_252 audience: type: string description: >- @@ -26960,7 +27976,7 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_248 + required: &ref_253 - authenticate - base_endpoint path: @@ -26982,7 +27998,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 auto_acknowledge_msg: type: boolean description: >- @@ -27009,7 +28025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -27025,7 +28041,7 @@ paths: type: array items: type: string - required: &ref_245 + required: &ref_250 - path - script_path - is_flow @@ -27062,8 +28078,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 responses: '200': description: gcp trigger updated @@ -27108,83 +28124,127 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: gcp trigger deleted content: application/json: schema: - allOf: &ref_250 + allOf: + - allOf: &ref_255 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + description: >- + A Google Cloud Pub/Sub trigger that executes a script or + flow when messages are received. + properties: &ref_256 + gcp_resource_path: + type: string + description: >- + Path to the GCP resource containing service account + credentials for authentication. + topic_id: + type: string + description: Google Cloud Pub/Sub topic ID to subscribe to. + subscription_id: + type: string + description: Google Cloud Pub/Sub subscription ID. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal use). + delivery_type: + type: string + enum: *ref_251 + description: >- + Delivery mode for messages. 'push' for HTTP push + delivery where messages are sent to a webhook + endpoint, 'pull' for polling where the trigger + actively fetches messages. + delivery_config: + nullable: true + type: object + description: Configuration for push delivery mode. + properties: *ref_252 + required: *ref_253 + subscription_mode: + type: string + enum: *ref_254 + description: >- + The mode of subscription. 'existing' means using an + existing GCP subscription, while 'create_update' + involves creating or updating a new subscription. + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal use). + error: + type: string + description: Last error message if the trigger failed. + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails. + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_257 + - gcp_resource_path + - topic_id + - subscription_id + - delivery_type + - subscription_mode - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - A Google Cloud Pub/Sub trigger that executes a script or flow - when messages are received. - properties: &ref_251 - gcp_resource_path: - type: string - description: >- - Path to the GCP resource containing service account - credentials for authentication. - topic_id: - type: string - description: Google Cloud Pub/Sub topic ID to subscribe to. - subscription_id: - type: string - description: Google Cloud Pub/Sub subscription ID. - server_id: - type: string - description: >- - ID of the server currently handling this trigger (internal - use). - delivery_type: - type: string - enum: *ref_246 - description: >- - Delivery mode for messages. 'push' for HTTP push delivery - where messages are sent to a webhook endpoint, 'pull' for - polling where the trigger actively fetches messages. - delivery_config: - nullable: true - type: object - description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 - subscription_mode: - type: string - enum: *ref_249 - description: >- - The mode of subscription. 'existing' means using an - existing GCP subscription, while 'create_update' involves - creating or updating a new subscription. - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal use). - error: - type: string - description: Last error message if the trigger failed. - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails. - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_252 - - gcp_resource_path - - topic_id - - subscription_id - - delivery_type - - subscription_mode + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/gcp_triggers/list: get: summary: list gcp triggers @@ -27223,6 +28283,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: gcp trigger list @@ -27231,13 +28301,13 @@ paths: schema: type: array items: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -27286,7 +28356,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27353,10 +28423,10 @@ paths: application/json: schema: type: object - properties: &ref_460 + properties: &ref_462 subscription_id: type: string - required: &ref_461 + required: &ref_463 - subscription_id responses: '200': @@ -27411,10 +28481,10 @@ paths: application/json: schema: type: object - properties: &ref_458 + properties: &ref_460 topic_id: type: string - required: &ref_459 + required: &ref_461 - topic_id responses: '200': @@ -27443,12 +28513,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_253 + properties: &ref_258 azure_resource_path: type: string azure_mode: type: string - enum: &ref_255 + enum: &ref_260 - basic_push - namespace_push - namespace_pull @@ -27480,7 +28550,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string error_handler_args: @@ -27490,7 +28560,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string preserve_permissioned_as: @@ -27499,7 +28569,7 @@ paths: type: array items: type: string - required: &ref_254 + required: &ref_259 - path - script_path - is_flow @@ -27536,8 +28606,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 responses: '200': description: azure trigger updated @@ -27582,65 +28652,110 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: azure trigger content: application/json: schema: - allOf: &ref_256 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - An Azure Event Grid trigger that executes a script or flow - when events arrive. - properties: &ref_257 - azure_resource_path: - type: string - azure_mode: - type: string - enum: *ref_255 - description: Azure Event Grid trigger mode. - scope_resource_id: - type: string + allOf: + - allOf: &ref_261 + - type: object + properties: *ref_219 + required: *ref_220 + type: object description: >- - ARM resource ID of the topic (basic) or namespace - (namespace modes). - topic_name: - type: string - nullable: true - description: Topic name within the namespace (namespace modes only). - subscription_name: - type: string - event_type_filters: - type: array - items: - type: string - nullable: true - server_id: - type: string - last_server_ping: - type: string - format: date-time - error: - type: string - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_258 - - azure_resource_path - - azure_mode - - scope_resource_id - - subscription_name + An Azure Event Grid trigger that executes a script or flow + when events arrive. + properties: &ref_262 + azure_resource_path: + type: string + azure_mode: + type: string + enum: *ref_260 + description: Azure Event Grid trigger mode. + scope_resource_id: + type: string + description: >- + ARM resource ID of the topic (basic) or namespace + (namespace modes). + topic_name: + type: string + nullable: true + description: >- + Topic name within the namespace (namespace modes + only). + subscription_name: + type: string + event_type_filters: + type: array + items: + type: string + nullable: true + server_id: + type: string + last_server_ping: + type: string + format: date-time + error: + type: string + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_263 + - azure_resource_path + - azure_mode + - scope_resource_id + - subscription_name + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/azure_triggers/list: get: summary: list azure triggers @@ -27673,6 +28788,16 @@ paths: in: query schema: type: string + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: azure trigger list @@ -27681,13 +28806,13 @@ paths: schema: type: array items: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -27735,7 +28860,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27767,10 +28892,10 @@ paths: application/json: schema: type: object - properties: &ref_464 + properties: &ref_466 azure_resource_path: type: string - required: &ref_465 + required: &ref_467 - azure_resource_path responses: '200': @@ -27800,10 +28925,10 @@ paths: application/json: schema: type: object - properties: &ref_466 + properties: &ref_468 scope_resource_id: type: string - required: &ref_467 + required: &ref_469 - scope_resource_id responses: '200': @@ -27835,12 +28960,12 @@ paths: application/json: schema: type: object - properties: &ref_468 + properties: &ref_470 scope_resource_id: type: string topic_name: type: string - required: &ref_469 + required: &ref_471 - scope_resource_id - topic_name responses: @@ -27873,10 +28998,10 @@ paths: application/json: schema: type: object - properties: &ref_462 + properties: &ref_464 azure_mode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -27885,7 +29010,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_463 + required: &ref_465 - azure_mode - scope_resource_id - subscription_name @@ -27921,7 +29046,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_259 + properties: &ref_264 id: type: string name: @@ -27930,7 +29055,7 @@ paths: type: string type: type: string - required: &ref_260 + required: &ref_265 - id - name - type @@ -27961,8 +29086,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -28025,19 +29150,19 @@ paths: application/json: schema: type: object - properties: &ref_478 + properties: &ref_480 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_262 + properties: &ref_267 schema_name: type: string table_to_track: type: array - items: &ref_476 + items: &ref_478 type: object properties: table_name: @@ -28050,14 +29175,14 @@ paths: type: string required: - table_name - required: &ref_263 + required: &ref_268 - schema_name - table_to_track language: type: string - enum: &ref_477 + enum: &ref_479 - Typescript - required: &ref_479 + required: &ref_481 - postgres_resource_path - relations - language @@ -28082,7 +29207,7 @@ paths: - name: id in: path required: true - schema: &ref_304 + schema: &ref_309 type: string responses: '200': @@ -28115,7 +29240,7 @@ paths: type: array items: type: object - properties: &ref_475 + properties: &ref_477 slot_name: type: string active: @@ -28142,7 +29267,7 @@ paths: application/json: schema: type: object - properties: &ref_261 + properties: &ref_266 name: type: string responses: @@ -28174,7 +29299,7 @@ paths: application/json: schema: type: object - properties: *ref_261 + properties: *ref_266 responses: '200': description: postgres replication slot deleted @@ -28225,7 +29350,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_264 + schema: &ref_269 type: string responses: '200': @@ -28234,18 +29359,18 @@ paths: application/json: schema: type: object - properties: &ref_265 + properties: &ref_270 table_to_track: type: array items: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 transaction_to_track: type: array items: type: string - required: &ref_266 + required: &ref_271 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -28266,7 +29391,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: new publication for postgres required: true @@ -28274,8 +29399,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication created @@ -28302,7 +29427,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: update publication for postgres required: true @@ -28310,8 +29435,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication updated @@ -28338,7 +29463,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 responses: '200': description: postgres publication deleted @@ -28364,7 +29489,7 @@ paths: application/json: schema: type: object - properties: &ref_480 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28392,7 +29517,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28403,8 +29528,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28415,7 +29540,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28431,7 +29556,7 @@ paths: type: array items: type: string - required: &ref_481 + required: &ref_483 - path - script_path - is_flow @@ -28466,7 +29591,7 @@ paths: application/json: schema: type: object - properties: &ref_482 + properties: &ref_484 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28494,7 +29619,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28505,8 +29630,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28517,7 +29642,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28533,7 +29658,7 @@ paths: type: array items: type: string - required: &ref_483 + required: &ref_485 - path - script_path - is_flow @@ -28585,60 +29710,103 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: get postgres trigger content: application/json: schema: - allOf: &ref_267 + allOf: + - allOf: &ref_272 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_273 + postgres_resource_path: + type: string + description: >- + Path to the PostgreSQL resource containing connection + configuration + publication_name: + type: string + description: >- + Name of the PostgreSQL publication to subscribe to for + change data capture + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + replication_slot_name: + type: string + description: Name of the PostgreSQL logical replication slot to use + error: + type: string + description: Last error message if the trigger failed + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_274 + - postgres_resource_path + - replication_slot_name + - publication_name - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_268 - postgres_resource_path: - type: string - description: >- - Path to the PostgreSQL resource containing connection - configuration - publication_name: - type: string - description: >- - Name of the PostgreSQL publication to subscribe to for - change data capture - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - replication_slot_name: - type: string - description: Name of the PostgreSQL logical replication slot to use - error: - type: string - description: Last error message if the trigger failed - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_269 - - postgres_resource_path - - replication_slot_name - - publication_name + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/postgres_triggers/list: get: summary: list postgres triggers @@ -28677,6 +29845,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: postgres trigger list @@ -28685,10 +29863,10 @@ paths: schema: type: array items: - allOf: *ref_267 + allOf: *ref_272 type: object - properties: *ref_268 - required: *ref_269 + properties: *ref_273 + required: *ref_274 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -28737,7 +29915,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -28800,7 +29978,7 @@ paths: application/json: schema: type: object - properties: &ref_492 + properties: &ref_494 path: type: string script_path: @@ -28820,11 +29998,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 permissioned_as: type: string description: >- @@ -28840,7 +30018,7 @@ paths: type: array items: type: string - required: &ref_493 + required: &ref_495 - path - script_path - local_part @@ -28874,7 +30052,7 @@ paths: application/json: schema: type: object - properties: &ref_494 + properties: &ref_496 path: type: string script_path: @@ -28894,7 +30072,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28910,7 +30088,7 @@ paths: type: array items: type: string - required: &ref_495 + required: &ref_497 - path - script_path - is_flow @@ -28958,34 +30136,77 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: email trigger retrieved content: application/json: schema: - allOf: &ref_270 + allOf: + - allOf: &ref_275 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_276 + local_part: + type: string + workspaced_local_part: + type: boolean + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_277 + - local_part - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_271 - local_part: - type: string - workspaced_local_part: - type: boolean - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_272 - - local_part + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/email_triggers/list: get: summary: list email triggers @@ -29024,6 +30245,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: email trigger list @@ -29032,10 +30263,10 @@ paths: schema: type: array items: - allOf: *ref_270 + allOf: *ref_275 type: object - properties: *ref_271 - required: *ref_272 + properties: *ref_276 + required: *ref_277 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -29117,7 +30348,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -29147,9 +30378,9 @@ paths: type: array items: type: object - required: &ref_496 + required: &ref_498 - name - properties: &ref_497 + properties: &ref_499 name: type: string summary: @@ -29179,9 +30410,9 @@ paths: type: array items: type: object - required: &ref_274 + required: &ref_279 - name - properties: &ref_275 + properties: &ref_280 name: type: string summary: @@ -29200,14 +30431,14 @@ paths: type: array items: type: object - properties: &ref_498 + properties: &ref_500 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_499 + required: &ref_501 - name /groups/get/{name}: get: @@ -29219,7 +30450,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group @@ -29227,8 +30458,8 @@ paths: application/json: schema: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 /groups/create: post: summary: create instance group @@ -29266,7 +30497,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update instance group required: true @@ -29302,7 +30533,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group deleted @@ -29320,7 +30551,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to add to instance group required: true @@ -29350,7 +30581,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to remove from instance group required: true @@ -29385,7 +30616,7 @@ paths: type: array items: type: object - properties: &ref_276 + properties: &ref_281 name: type: string summary: @@ -29406,7 +30637,7 @@ paths: enum: - superadmin - devops - required: &ref_277 + required: &ref_282 - name /groups/overwrite: post: @@ -29423,8 +30654,8 @@ paths: type: array items: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 responses: '200': description: success message @@ -29460,7 +30691,7 @@ paths: type: array items: type: object - properties: &ref_278 + properties: &ref_283 name: type: string summary: @@ -29473,7 +30704,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_279 + required: &ref_284 - name /w/{workspace}/groups/listnames: get: @@ -29546,7 +30777,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: updated group required: true @@ -29578,7 +30809,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group deleted @@ -29600,7 +30831,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group @@ -29608,8 +30839,8 @@ paths: application/json: schema: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -29624,7 +30855,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29656,7 +30887,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29688,7 +30919,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -29747,7 +30978,7 @@ paths: type: array items: type: object - properties: &ref_281 + properties: &ref_286 name: type: string owners: @@ -29773,7 +31004,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_280 + items: &ref_285 type: object required: - path_glob @@ -29794,7 +31025,15 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_282 + labels: + type: array + items: + type: string + description: > + Labels set on the folder. Items inside the folder + inherit them, exposed as `inherited_labels` on scripts + and flows and stamped into job labels at run time. + required: &ref_287 - name - owners - extra_perms @@ -29861,7 +31100,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string required: - name responses: @@ -29885,7 +31128,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update folder required: true @@ -29911,7 +31154,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string responses: '200': description: folder updated @@ -29933,7 +31180,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder deleted @@ -29955,7 +31202,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -29963,8 +31210,8 @@ paths: application/json: schema: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -29979,7 +31226,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder exists @@ -30001,7 +31248,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -30043,7 +31290,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: owner user to folder required: true @@ -30077,7 +31324,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added owner to folder required: true @@ -30113,7 +31360,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -30177,7 +31424,7 @@ paths: type: array items: type: object - properties: &ref_500 + properties: &ref_502 worker: type: string worker_instance: @@ -30223,7 +31470,7 @@ paths: type: string native_mode: type: boolean - required: &ref_501 + required: &ref_503 - worker - worker_instance - ping_at @@ -30388,7 +31635,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: a config @@ -30397,12 +31644,12 @@ paths: schema: type: object nullable: true - properties: &ref_384 + properties: &ref_389 alerts: type: array items: type: object - properties: &ref_382 + properties: &ref_387 name: type: string tags_to_monitor: @@ -30415,7 +31662,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_383 + required: &ref_388 - name - tags_to_monitor - jobs_num_threshold @@ -30431,7 +31678,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: worker group required: true @@ -30454,7 +31701,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: Delete config @@ -30477,12 +31724,12 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_548 name: type: string config: type: object - required: &ref_548 + required: &ref_549 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -30513,7 +31760,7 @@ paths: type: array items: type: object - properties: &ref_551 + properties: &ref_552 id: type: integer format: int64 @@ -30580,7 +31827,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 required: - workspace_id - language @@ -30606,7 +31853,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_dep_names: type: array items: @@ -30946,7 +32193,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_283 + enum: &ref_288 - webhook - http - websocket @@ -30992,11 +32239,11 @@ paths: required: true schema: type: string - enum: *ref_283 + enum: *ref_288 - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31018,7 +32265,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31032,17 +32279,17 @@ paths: type: array items: type: object - properties: &ref_552 + properties: &ref_553 trigger_config: {} trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 error: type: string last_server_ping: type: string format: date-time - required: &ref_553 + required: &ref_554 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -31058,7 +32305,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31067,7 +32314,7 @@ paths: in: query schema: type: string - enum: *ref_283 + enum: *ref_288 - name: page description: which page to return (start at 1, default 1) in: query @@ -31085,10 +32332,10 @@ paths: type: array items: type: object - properties: &ref_284 + properties: &ref_289 trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 main_args: {} preprocessor_args: {} id: @@ -31096,7 +32343,7 @@ paths: created_at: type: string format: date-time - required: &ref_285 + required: &ref_290 - trigger_kind - main_args - preprocessor_args @@ -31116,7 +32363,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31161,8 +32408,8 @@ paths: application/json: schema: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 delete: summary: delete a capture operationId: deleteCapture @@ -31254,13 +32501,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_286 + schema: &ref_291 type: string - name: runnable_type in: query - schema: &ref_287 + schema: &ref_292 type: string - enum: &ref_392 + enum: &ref_398 - ScriptHash - ScriptPath - FlowPath @@ -31277,7 +32524,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: include_preview in: query schema: @@ -31291,7 +32538,7 @@ paths: type: array items: type: object - properties: &ref_288 + properties: &ref_293 id: type: string name: @@ -31305,7 +32552,7 @@ paths: type: boolean success: type: boolean - required: &ref_289 + required: &ref_294 - id - name - args @@ -31355,10 +32602,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 - name: page description: which page to return (start at 1, default 1) in: query @@ -31376,8 +32623,8 @@ paths: type: array items: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -31391,10 +32638,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 requestBody: description: Input required: true @@ -31402,12 +32649,12 @@ paths: application/json: schema: type: object - properties: &ref_388 + properties: &ref_394 name: type: string args: type: object - required: &ref_389 + required: &ref_395 - name - args - created_by @@ -31437,14 +32684,14 @@ paths: application/json: schema: type: object - properties: &ref_390 + properties: &ref_396 id: type: string name: type: string is_public: type: boolean - required: &ref_391 + required: &ref_397 - id - name - is_public @@ -31470,7 +32717,7 @@ paths: - name: input in: path required: true - schema: &ref_312 + schema: &ref_317 type: string responses: '200': @@ -31503,7 +32750,7 @@ paths: properties: s3_resource: type: object - properties: &ref_290 + properties: &ref_295 bucket: type: string region: @@ -31518,7 +32765,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_291 + required: &ref_296 - bucket - region - endPoint @@ -31596,8 +32843,8 @@ paths: properties: s3_resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 responses: '200': description: Connection settings @@ -31618,10 +32865,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_292 + properties: &ref_297 region_name: type: string - required: &ref_293 + required: &ref_298 - region_name required: - endpoint_url @@ -31676,8 +32923,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 required: - endpoint_url - use_ssl @@ -31735,8 +32982,8 @@ paths: application/json: schema: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -31800,10 +33047,10 @@ paths: type: array items: type: object - properties: &ref_294 + properties: &ref_299 s3: type: string - required: &ref_295 + required: &ref_300 - s3 restricted_access: type: boolean @@ -31836,7 +33083,7 @@ paths: application/json: schema: type: object - properties: &ref_298 + properties: &ref_303 mime_type: type: string size_in_bytes: @@ -31900,7 +33147,7 @@ paths: application/json: schema: type: object - properties: &ref_296 + properties: &ref_301 msg: type: string content: @@ -31912,7 +33159,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_297 + required: &ref_302 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -31960,8 +33207,8 @@ paths: type: array items: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 restricted_access: type: boolean required: @@ -32020,8 +33267,8 @@ paths: application/json: schema: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -32052,7 +33299,7 @@ paths: application/json: schema: type: object - properties: *ref_298 + properties: *ref_303 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -32503,7 +33750,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32532,46 +33779,46 @@ paths: type: array items: type: object - properties: &ref_533 + properties: &ref_534 id: type: string name: type: string - required: &ref_534 + required: &ref_535 - id scalar_metrics: type: array items: type: object - properties: &ref_535 + properties: &ref_536 metric_id: type: string value: type: number - required: &ref_536 + required: &ref_537 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_537 + properties: &ref_538 metric_id: type: string values: type: array items: type: object - properties: &ref_539 + properties: &ref_540 timestamp: type: string format: date-time value: type: number - required: &ref_540 + required: &ref_541 - timestamp - value - required: &ref_538 + required: &ref_539 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -32588,7 +33835,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32622,7 +33869,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job progress between 0 and 99 @@ -32640,11 +33887,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 - name: with_error in: query required: false @@ -32716,12 +33963,12 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_542 concurrency_key: type: string total_running: type: number - required: &ref_542 + required: &ref_543 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -32734,7 +33981,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_314 + schema: &ref_319 type: string responses: '200': @@ -32754,7 +34001,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concurrency key for given job @@ -32790,104 +34037,104 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: page description: which page to return (start at 1, default 1) in: query @@ -32903,7 +34150,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -32924,6 +34171,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -32943,17 +34203,17 @@ paths: application/json: schema: type: object - properties: &ref_543 + properties: &ref_544 jobs: type: array items: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 obscured_jobs: type: array items: type: object - properties: &ref_393 + properties: &ref_399 typ: type: string started_at: @@ -32966,7 +34226,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_544 + required: &ref_545 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -33010,7 +34270,7 @@ paths: type: array items: type: object - properties: &ref_549 + properties: &ref_550 dancer: type: string hit_count: @@ -33089,7 +34349,7 @@ paths: type: array items: type: object - properties: &ref_550 + properties: &ref_551 dancer: type: string /srch/index/search/count_service_logs: @@ -33364,7 +34624,7 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 usages: type: array items: @@ -33377,13 +34637,13 @@ paths: type: string kind: type: string - enum: &ref_303 + enum: &ref_308 - script - flow - job access_type: type: string - enum: &ref_302 + enum: &ref_307 - r - w - rw @@ -33393,7 +34653,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_302 + enum: *ref_307 nullable: true created_at: type: string @@ -33466,7 +34726,7 @@ paths: type: string kind: type: string - enum: *ref_303 + enum: *ref_308 responses: '200': description: all assets used by the given usage paths, in the same order @@ -33486,10 +34746,10 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 access_type: type: string - enum: *ref_302 + enum: *ref_307 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -33537,13 +34797,13 @@ paths: type: array items: type: object - required: &ref_563 + required: &ref_564 - name - size_bytes - file_count - created_at - created_by - properties: &ref_564 + properties: &ref_565 name: type: string size_bytes: @@ -33658,13 +34918,13 @@ paths: type: array items: type: object - required: &ref_376 + required: &ref_381 - name - description - instructions - path - method - properties: &ref_377 + properties: &ref_382 name: type: string description: The tool name/operation ID @@ -33797,11 +35057,30 @@ components: in: cookie name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the deployed + payload. + schema: *ref_77 + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 Id: name: id in: path required: true - schema: *ref_304 + schema: *ref_309 Key: name: key in: path @@ -33817,7 +35096,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 VersionId: name: version in: path @@ -33828,7 +35107,7 @@ components: name: token in: path required: true - schema: *ref_305 + schema: *ref_310 AccountId: name: id in: path @@ -33843,17 +35122,17 @@ components: name: path in: path required: true - schema: *ref_92 + schema: *ref_97 ScriptHash: name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 JobId: name: id in: path required: true - schema: *ref_172 + schema: *ref_176 Path: name: path in: path @@ -33863,22 +35142,22 @@ components: name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 PathId: name: id in: path required: true - schema: *ref_79 + schema: *ref_84 PathVersion: name: version in: path required: true - schema: *ref_306 + schema: *ref_311 Name: name: name in: path required: true - schema: *ref_273 + schema: *ref_278 Page: name: page description: which page to return (start at 1, default 1) @@ -33897,12 +35176,12 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 OrderDesc: name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 CreatedBy: name: created_by description: >- @@ -33910,7 +35189,7 @@ components: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 Label: name: label description: >- @@ -33918,7 +35197,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 Worker: name: worker description: >- @@ -33926,26 +35205,26 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 ParentJob: name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 WorkerTag: name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 CacheTtl: name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 NewJobId: name: job_id description: >- @@ -33953,7 +35232,7 @@ components: randomly using the ULID scheme. If a job id already exists in the queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 IncludeHeader: name: include_header description: > @@ -33963,19 +35242,19 @@ components: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 QueueLimit: name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 SkipPreprocessor: name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 Payload: name: payload description: > @@ -33984,7 +35263,7 @@ components: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 ScriptStartPath: name: script_path_start description: >- @@ -33992,12 +35271,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 TriggerPath: name: trigger_path description: >- @@ -34005,7 +35284,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_307 + schema: *ref_312 ScriptExactPath: name: script_path_exact description: >- @@ -34013,87 +35292,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 Success: name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 Running: name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 Tag: name: tag description: >- @@ -34101,37 +35380,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 Username: name: username description: filter on exact username of user in: query - schema: *ref_308 + schema: *ref_313 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_309 + schema: *ref_314 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_310 + schema: *ref_315 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_311 + schema: *ref_316 JobKinds: name: job_kinds description: >- @@ -34139,53 +35418,76 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 RunnableId: name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 InputId: name: input in: path required: true - schema: *ref_312 + schema: *ref_317 GetStarted: name: get_started in: query - schema: *ref_313 + schema: *ref_318 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_314 + schema: *ref_319 RunnableKind: name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 schemas: + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 OpenFlow: type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 Retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 StopAfterIf: type: object description: Early termination condition for a module @@ -34206,6 +35508,13 @@ components: with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised. + error_include_result: + type: boolean + description: >- + When stopping with an error (error_message set), embed the stopping + step's own result inside the raised error object (as error.result) + instead of discarding it. The top-level result stays { error }. + Defaults to false. required: - expr FlowModule: @@ -34222,18 +35531,18 @@ components: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -34252,8 +35561,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: Cache duration in seconds for this step's results @@ -34264,8 +35573,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -34308,8 +35617,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: If true, the user who started the flow cannot approve @@ -34328,7 +35637,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_315 + properties: *ref_320 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -34361,8 +35670,8 @@ components: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 StaticTransform: type: object description: >- @@ -34419,7 +35728,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 resource: type: string description: >- @@ -34439,16 +35748,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 - type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 - type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 discriminator: propertyName: kind mapping: @@ -34465,62 +35774,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 - type: object description: >- Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 - type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 - type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 - type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 discriminator: propertyName: type mapping: @@ -34550,8 +35859,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 content: type: string description: The script source code. Should export a 'main' function @@ -34667,8 +35976,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the script in the workspace (e.g., 'f/scripts/send_email') @@ -34705,8 +36014,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the flow in the workspace (e.g., 'f/flows/process_user') @@ -34734,15 +36043,15 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 iterator: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -34762,8 +36071,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34786,8 +36095,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -34807,8 +36116,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34846,8 +36155,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -34857,8 +36166,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: @@ -34896,8 +36205,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -34926,16 +36235,16 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 output_type: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -34947,8 +36256,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax. @@ -34958,8 +36267,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. Optional. @@ -34969,8 +36278,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -34980,16 +36289,16 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 output_schema: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape. @@ -35006,8 +36315,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -35021,8 +36330,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -35035,8 +36344,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: | Float. Controls randomness/creativity of responses. Range: 0.0 to 2.0 (provider-dependent) @@ -35049,8 +36358,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -35070,12 +36379,17 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. If not set, the AI agent + step runs on the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -35106,8 +36420,8 @@ components: - type FlowStatus: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 FlowStatusModule: type: object properties: @@ -35329,8 +36643,8 @@ components: - type CiTestResult: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 HealthStatusResponse: type: object description: Health status response (cached with 5s TTL) @@ -35344,75 +36658,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_347 - properties: *ref_348 + required: *ref_352 + properties: *ref_353 DatabaseHealth: type: object description: Database health status - required: *ref_349 - properties: *ref_350 + required: *ref_354 + properties: *ref_355 PoolStats: type: object description: Database connection pool statistics - required: *ref_351 - properties: *ref_352 + required: *ref_356 + properties: *ref_357 WorkersHealth: type: object description: Workers health status - required: *ref_353 - properties: *ref_354 + required: *ref_358 + properties: *ref_359 QueueHealth: type: object description: Job queue status - required: *ref_355 - properties: *ref_356 + required: *ref_360 + properties: *ref_361 ReadinessHealth: type: object description: Server readiness status - required: *ref_357 - properties: *ref_358 + required: *ref_362 + properties: *ref_363 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_359 + properties: *ref_364 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_360 + properties: *ref_365 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_361 + properties: *ref_366 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_362 + oneOf: *ref_367 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_363 + properties: *ref_368 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_364 + properties: *ref_369 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_365 + oneOf: *ref_370 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_366 + properties: *ref_371 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_367 + properties: *ref_372 VaultSettings: type: object required: *ref_27 @@ -35427,28 +36741,28 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_368 - properties: *ref_369 + required: *ref_373 + properties: *ref_374 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_370 - properties: *ref_371 + required: *ref_375 + properties: *ref_376 FlowConversation: type: object - required: *ref_372 - properties: *ref_373 + required: *ref_377 + properties: *ref_378 FlowConversationMessage: type: object - required: *ref_374 - properties: *ref_375 + required: *ref_379 + properties: *ref_380 EndpointTool: type: object - required: *ref_376 - properties: *ref_377 + required: *ref_381 + properties: *ref_382 AIProvider: type: string enum: *ref_51 @@ -35461,114 +36775,112 @@ components: required: *ref_44 AIProviderConfig: type: object - properties: *ref_378 - required: *ref_379 + properties: *ref_383 + required: *ref_384 AIConfig: type: object properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_380 - required: *ref_381 + properties: *ref_385 + required: *ref_386 InstanceAISummary: type: object properties: *ref_52 required: *ref_53 Alert: type: object - properties: *ref_382 - required: *ref_383 + properties: *ref_387 + required: *ref_388 Configs: type: object nullable: true - properties: *ref_384 + properties: *ref_389 WorkspaceDependencies: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 NewWorkspaceDependencies: type: object - properties: *ref_385 - required: *ref_386 + properties: *ref_390 + required: *ref_391 Script: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 NewScript: type: object - properties: *ref_104 - required: *ref_105 - NewScriptWithDraft: - allOf: *ref_387 + properties: *ref_392 + required: *ref_393 ScriptHistory: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 ScriptArgs: type: object description: The arguments to pass to the script or flow additionalProperties: true Input: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 CreateInput: type: object - properties: *ref_388 - required: *ref_389 + properties: *ref_394 + required: *ref_395 UpdateInput: type: object - properties: *ref_390 - required: *ref_391 + properties: *ref_396 + required: *ref_397 RunnableType: type: string - enum: *ref_392 + enum: *ref_398 QueuedJob: type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 CompletedJob: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 ObscuredJob: type: object - properties: *ref_393 + properties: *ref_399 Job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_394 - required: *ref_395 + properties: *ref_400 + required: *ref_401 UserUsage: type: object - properties: *ref_396 + properties: *ref_402 Login: type: object - properties: *ref_397 - required: *ref_398 + properties: *ref_403 + required: *ref_404 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_399 + properties: *ref_405 OffboardAffectedPaths: type: object properties: *ref_11 @@ -35578,64 +36890,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_400 - required: *ref_401 + properties: *ref_406 + required: *ref_407 OffboardRequest: type: object - properties: *ref_402 - required: *ref_403 + properties: *ref_408 + required: *ref_409 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_404 - required: *ref_405 + properties: *ref_410 + required: *ref_411 GlobalOffboardPreview: type: object - properties: *ref_406 - required: *ref_407 + properties: *ref_412 + required: *ref_413 WorkspaceOffboardPreview: type: object - properties: *ref_408 - required: *ref_409 + properties: *ref_414 + required: *ref_415 GlobalOffboardRequest: type: object - properties: *ref_410 + properties: *ref_416 WorkspaceReassignment: type: object - properties: *ref_411 - required: *ref_412 + properties: *ref_417 + required: *ref_418 TruncatedToken: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 ExternalJwtToken: type: object - properties: *ref_413 - required: *ref_414 + properties: *ref_419 + required: *ref_420 NewToken: type: object - properties: *ref_415 + properties: *ref_421 NewTokenImpersonate: type: object - properties: *ref_416 - required: *ref_417 + properties: *ref_422 + required: *ref_423 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_418 - required: *ref_419 + properties: *ref_424 + required: *ref_425 CreateVariable: type: object - properties: *ref_420 - required: *ref_421 + properties: *ref_426 + required: *ref_427 EditVariable: type: object - properties: *ref_422 + properties: *ref_428 AuditLog: type: object properties: *ref_5 @@ -35772,63 +37084,99 @@ components: - has_preprocessor ScriptLang: type: string - enum: *ref_94 + enum: *ref_99 ScriptModule: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 Preview: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 PreviewInline: type: object - properties: *ref_423 - required: *ref_424 + properties: *ref_429 + required: *ref_430 InlineScriptArgs: type: object - properties: *ref_140 + properties: *ref_144 WorkflowTask: type: object - properties: *ref_425 - required: *ref_426 + properties: *ref_431 + required: *ref_432 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_177 + properties: *ref_181 WorkflowStatus: type: object - properties: *ref_177 + properties: *ref_181 CreateResource: type: object - properties: *ref_427 - required: *ref_428 + properties: *ref_433 + required: *ref_434 EditResource: type: object - properties: *ref_429 + properties: *ref_435 Resource: type: object - properties: *ref_430 - required: *ref_431 + properties: + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. + ws_specific: + type: boolean + required: + - path + - resource_type + - is_oauth ListableResource: type: object - properties: *ref_432 - required: *ref_433 + properties: *ref_80 + required: *ref_81 ResourceType: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 EditResourceType: type: object - properties: *ref_434 + properties: *ref_436 Schedule: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 ScheduleWJobs: - allOf: *ref_435 + allOf: *ref_437 ErrorHandler: type: string enum: @@ -35837,122 +37185,122 @@ components: - teams - email NewSchedule: - type: object - properties: *ref_436 - required: *ref_437 - EditSchedule: type: object properties: *ref_438 required: *ref_439 + EditSchedule: + type: object + properties: *ref_440 + required: *ref_441 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 TriggerMode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 TriggerExtraProperty: type: object - properties: *ref_214 - required: *ref_215 + properties: *ref_219 + required: *ref_220 AuthenticationMethod: type: string - enum: *ref_206 + enum: *ref_210 RunnableKind: type: string - enum: *ref_200 + enum: *ref_204 OpenapiSpecFormat: type: string - enum: *ref_440 + enum: *ref_442 OpenapiHttpRouteFilters: - type: object - properties: *ref_441 - required: *ref_442 - WebhookFilters: type: object properties: *ref_443 required: *ref_444 - OpenapiV3Info: + WebhookFilters: type: object properties: *ref_445 required: *ref_446 - GenerateOpenapiSpec: - type: object - properties: *ref_201 - HttpMethod: - type: string - enum: *ref_204 - HttpRequestType: - type: string - enum: *ref_205 - HttpTrigger: - allOf: *ref_208 - type: object - properties: *ref_209 - required: *ref_210 - NewHttpTrigger: - type: object - properties: *ref_202 - required: *ref_203 - EditHttpTrigger: + OpenapiV3Info: type: object properties: *ref_447 required: *ref_448 - TriggersCount: + GenerateOpenapiSpec: type: object - properties: *ref_125 - WebsocketHeartbeat: + properties: *ref_205 + HttpMethod: + type: string + enum: *ref_208 + HttpRequestType: + type: string + enum: *ref_209 + HttpTrigger: + allOf: *ref_213 type: object - properties: *ref_212 - required: *ref_213 - WebsocketTrigger: - allOf: *ref_216 + properties: *ref_214 + required: *ref_215 + NewHttpTrigger: type: object - properties: *ref_217 - required: *ref_218 - NewWebsocketTrigger: + properties: *ref_206 + required: *ref_207 + EditHttpTrigger: type: object properties: *ref_449 required: *ref_450 - EditWebsocketTrigger: + TriggersCount: + type: object + properties: *ref_129 + WebsocketHeartbeat: + type: object + properties: *ref_217 + required: *ref_218 + WebsocketTrigger: + allOf: *ref_221 + type: object + properties: *ref_222 + required: *ref_223 + NewWebsocketTrigger: type: object properties: *ref_451 required: *ref_452 + EditWebsocketTrigger: + type: object + properties: *ref_453 + required: *ref_454 WebsocketTriggerInitialMessage: - anyOf: *ref_211 + anyOf: *ref_216 MqttQoS: type: string - enum: *ref_453 + enum: *ref_455 MqttV3Config: type: object - properties: *ref_238 + properties: *ref_243 MqttV5Config: type: object - properties: *ref_239 + properties: *ref_244 MqttSubscribeTopic: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 MqttClientVersion: type: string - enum: *ref_240 + enum: *ref_245 MqttTrigger: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 NewMqttTrigger: - type: object - properties: *ref_454 - required: *ref_455 - EditMqttTrigger: type: object properties: *ref_456 required: *ref_457 + EditMqttTrigger: + type: object + properties: *ref_458 + required: *ref_459 DeliveryType: type: string - enum: *ref_246 + enum: *ref_251 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -35960,19 +37308,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 + properties: *ref_252 + required: *ref_253 GcpTrigger: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 SubscriptionMode: type: string - enum: *ref_249 + enum: *ref_254 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -35980,68 +37328,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 GetAllTopicSubscription: - type: object - properties: *ref_458 - required: *ref_459 - DeleteGcpSubscription: type: object properties: *ref_460 required: *ref_461 + DeleteGcpSubscription: + type: object + properties: *ref_462 + required: *ref_463 AzureMode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 AzureDeleteSubscription: type: object - properties: *ref_462 - required: *ref_463 + properties: *ref_464 + required: *ref_465 AzureTrigger: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 TestAzureConnection: - type: object - properties: *ref_464 - required: *ref_465 - AzureListTopics: type: object properties: *ref_466 required: *ref_467 - AzureListSubscriptions: + AzureListTopics: type: object properties: *ref_468 required: *ref_469 + AzureListSubscriptions: + type: object + properties: *ref_470 + required: *ref_471 AwsAuthResourceType: type: string - enum: *ref_225 + enum: *ref_230 SqsTrigger: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_470 + properties: *ref_472 CustomInstanceDbTag: type: string enum: *ref_22 @@ -36050,109 +37398,109 @@ components: required: *ref_23 properties: *ref_24 NewSqsTrigger: - type: object - properties: *ref_471 - required: *ref_472 - EditSqsTrigger: type: object properties: *ref_473 required: *ref_474 - Slot: - type: object - properties: *ref_261 - SlotList: + EditSqsTrigger: type: object properties: *ref_475 + required: *ref_476 + Slot: + type: object + properties: *ref_266 + SlotList: + type: object + properties: *ref_477 PublicationData: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 TableToTrack: type: array - items: *ref_476 + items: *ref_478 Relations: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 Language: type: string - enum: *ref_477 + enum: *ref_479 TemplateScript: - type: object - properties: *ref_478 - required: *ref_479 - PostgresTrigger: - allOf: *ref_267 - type: object - properties: *ref_268 - required: *ref_269 - NewPostgresTrigger: type: object properties: *ref_480 required: *ref_481 - EditPostgresTrigger: + PostgresTrigger: + allOf: *ref_272 + type: object + properties: *ref_273 + required: *ref_274 + NewPostgresTrigger: type: object properties: *ref_482 required: *ref_483 - KafkaTrigger: - allOf: *ref_219 - type: object - properties: *ref_220 - required: *ref_221 - NewKafkaTrigger: + EditPostgresTrigger: type: object properties: *ref_484 required: *ref_485 - EditKafkaTrigger: + KafkaTrigger: + allOf: *ref_224 + type: object + properties: *ref_225 + required: *ref_226 + NewKafkaTrigger: type: object properties: *ref_486 required: *ref_487 - NatsTrigger: - allOf: *ref_222 - type: object - properties: *ref_223 - required: *ref_224 - NewNatsTrigger: + EditKafkaTrigger: type: object properties: *ref_488 required: *ref_489 - EditNatsTrigger: + NatsTrigger: + allOf: *ref_227 + type: object + properties: *ref_228 + required: *ref_229 + NewNatsTrigger: type: object properties: *ref_490 required: *ref_491 - EmailTrigger: - allOf: *ref_270 - type: object - properties: *ref_271 - required: *ref_272 - NewEmailTrigger: + EditNatsTrigger: type: object properties: *ref_492 required: *ref_493 - EditEmailTrigger: + EmailTrigger: + allOf: *ref_275 + type: object + properties: *ref_276 + required: *ref_277 + NewEmailTrigger: type: object properties: *ref_494 required: *ref_495 + EditEmailTrigger: + type: object + properties: *ref_496 + required: *ref_497 Group: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 InstanceGroup: type: object - required: *ref_496 - properties: *ref_497 + required: *ref_498 + properties: *ref_499 InstanceGroupWithWorkspaces: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 WorkspaceInfo: type: object - properties: *ref_498 - required: *ref_499 + properties: *ref_500 + required: *ref_501 Folder: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -36160,19 +37508,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 WorkerPing: - type: object - properties: *ref_500 - required: *ref_501 - UserWorkspaceList: type: object properties: *ref_502 required: *ref_503 - CreateWorkspace: + UserWorkspaceList: type: object properties: *ref_504 required: *ref_505 + CreateWorkspace: + type: object + properties: *ref_506 + required: *ref_507 CreateWorkspaceFork: type: object properties: *ref_19 @@ -36183,15 +37531,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_506 + properties: *ref_508 DependencyDependent: - type: object - properties: *ref_507 - required: *ref_508 - DependentsAmount: type: object properties: *ref_509 required: *ref_510 + DependentsAmount: + type: object + properties: *ref_511 + required: *ref_512 WorkspaceInvite: type: object properties: *ref_41 @@ -36201,56 +37549,54 @@ components: properties: *ref_39 required: *ref_40 Flow: - allOf: *ref_124 + allOf: *ref_128 ExtraPerms: type: object - additionalProperties: *ref_511 + additionalProperties: *ref_513 FlowMetadata: - type: object - properties: *ref_512 - required: *ref_513 - OpenFlowWPath: - allOf: *ref_126 - FlowPreview: - type: object - properties: *ref_152 - required: *ref_153 - RestartedFrom: - type: object - properties: *ref_151 - Policy: - type: object - properties: *ref_127 - ListableApp: type: object properties: *ref_514 required: *ref_515 - ScopeDefinition: + OpenFlowWPath: + allOf: *ref_130 + FlowPreview: + type: object + properties: *ref_156 + required: *ref_157 + RestartedFrom: + type: object + properties: *ref_155 + Policy: + type: object + properties: *ref_131 + ListableApp: type: object properties: *ref_516 required: *ref_517 - ScopeDomain: + ScopeDefinition: type: object properties: *ref_518 required: *ref_519 - ListableRawApp: + ScopeDomain: type: object properties: *ref_520 required: *ref_521 + ListableRawApp: + type: object + properties: *ref_522 + required: *ref_523 AppWithLastVersion: type: object - properties: *ref_128 - required: *ref_129 - AppWithLastVersionWDraft: - allOf: *ref_522 + properties: *ref_132 + required: *ref_133 AppHistory: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 FlowVersion: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 SlackToken: type: object properties: @@ -36274,11 +37620,11 @@ components: required: *ref_75 HubScriptKind: type: string - enum: *ref_93 + enum: *ref_98 PolarsClientKwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 LargeFileStorage: type: object properties: *ref_45 @@ -36292,35 +37638,35 @@ components: properties: *ref_47 DataTableSchema: type: object - required: *ref_523 - properties: *ref_524 + required: *ref_524 + properties: *ref_525 DataTableTables: type: object - required: *ref_525 - properties: *ref_526 + required: *ref_526 + properties: *ref_527 DataTableTableSchema: type: object - required: *ref_527 - properties: *ref_528 + required: *ref_528 + properties: *ref_529 DynamicInputData: type: object - properties: *ref_529 - required: *ref_530 + properties: *ref_530 + required: *ref_531 WindmillLargeFile: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 WindmillFileMetadata: type: object - properties: *ref_298 + properties: *ref_303 WindmillFilePreview: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 S3Resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 WorkspaceGitSyncSettings: type: object properties: *ref_56 @@ -36332,48 +37678,48 @@ components: properties: *ref_59 S3PermissionRule: type: object - properties: *ref_531 - required: *ref_532 + properties: *ref_532 + required: *ref_533 GitRepositorySettings: type: object properties: *ref_57 required: *ref_58 MetricMetadata: type: object - properties: *ref_533 - required: *ref_534 + properties: *ref_534 + required: *ref_535 ScalarMetric: type: object - properties: *ref_535 - required: *ref_536 + properties: *ref_536 + required: *ref_537 TimeseriesMetric: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_538 + required: *ref_539 MetricDataPoint: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_540 + required: *ref_541 RawScriptForDependencies: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 ConcurrencyGroup: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_542 + required: *ref_543 ExtendedJobs: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_544 + required: *ref_545 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_545 - required: *ref_546 + properties: *ref_546 + required: *ref_547 InstanceConfig: type: object description: >- @@ -36382,35 +37728,35 @@ components: properties: *ref_26 Config: type: object - properties: *ref_547 - required: *ref_548 + properties: *ref_548 + required: *ref_549 ExportedInstanceGroup: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 JobSearchHit: type: object - properties: *ref_549 + properties: *ref_550 LogSearchHit: type: object - properties: *ref_550 + properties: *ref_551 AutoscalingEvent: type: object - properties: *ref_551 + properties: *ref_552 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_283 + enum: *ref_288 Capture: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 CaptureConfig: type: object - properties: *ref_552 - required: *ref_553 + properties: *ref_553 + required: *ref_554 OperatorSettings: nullable: true type: object @@ -36418,16 +37764,16 @@ components: properties: *ref_38 WorkspaceComparison: type: object - required: *ref_554 - properties: *ref_555 + required: *ref_555 + properties: *ref_556 WorkspaceItemDiff: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_557 + properties: *ref_558 CompareSummary: type: object - required: *ref_558 - properties: *ref_559 + required: *ref_559 + properties: *ref_560 TeamInfo: type: object required: @@ -36448,12 +37794,12 @@ components: description: List of channels within the team items: type: object - required: &ref_560 + required: &ref_561 - channel_id - channel_name - tenant_id - service_url - properties: &ref_561 + properties: &ref_562 channel_id: type: string description: The unique identifier of the channel @@ -36473,11 +37819,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_560 - properties: *ref_561 + required: *ref_561 + properties: *ref_562 GithubInstallations: type: array - items: *ref_562 + items: *ref_563 WorkspaceGithubInstallation: type: object properties: @@ -36490,8 +37836,8 @@ components: - installation_id S3Object: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 TeamsChannel: type: object required: @@ -36518,14 +37864,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_303 + enum: *ref_308 AssetUsageAccessType: type: string - enum: *ref_302 + enum: *ref_307 nullable: true AssetKind: type: string - enum: *ref_301 + enum: *ref_306 Asset: type: object properties: @@ -36533,26 +37879,26 @@ components: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 required: - path - kind Volume: type: object - required: *ref_563 - properties: *ref_564 + required: *ref_564 + properties: *ref_565 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_565 - properties: *ref_566 + required: *ref_566 + properties: *ref_567 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_567 + enum: *ref_568 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -36563,12 +37909,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_568 - properties: *ref_569 + required: *ref_569 + properties: *ref_570 DeploymentRequestAssignee: type: object - required: *ref_570 - properties: *ref_571 + required: *ref_571 + properties: *ref_572 DeploymentRequestComment: type: object required: *ref_69 @@ -36583,27 +37929,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_229 + enum: *ref_234 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_572 - required: *ref_573 + properties: *ref_573 + required: *ref_574 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_574 - required: *ref_575 + properties: *ref_575 + required: *ref_576 WorkspaceIntegrations: type: object - properties: *ref_576 - required: *ref_577 + properties: *ref_577 + required: *ref_578 WorkspaceOAuthConfig: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 WebhookEvent: type: object properties: @@ -36614,7 +37960,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_578 + enum: &ref_579 - async - sync required: @@ -36623,21 +37969,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_578 + enum: *ref_579 RedirectUri: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_579 - required: *ref_580 + properties: *ref_580 + required: *ref_581 SyncResult: type: object properties: @@ -36661,35 +38007,35 @@ components: - total_windmill NextCloudEventType: type: object - properties: *ref_581 - required: *ref_582 + properties: *ref_582 + required: *ref_583 GoogleCalendarEntry: type: object - properties: *ref_583 - required: *ref_584 + properties: *ref_584 + required: *ref_585 GoogleDriveFile: type: object - properties: *ref_585 - required: *ref_586 + properties: *ref_586 + required: *ref_587 GoogleDriveFilesResponse: type: object - properties: *ref_587 - required: *ref_588 + properties: *ref_588 + required: *ref_589 SharedDriveEntry: type: object - properties: *ref_589 - required: *ref_590 + properties: *ref_590 + required: *ref_591 GithubRepoEntry: type: object - properties: *ref_591 - required: *ref_592 + properties: *ref_592 + required: *ref_593 schemas-StaticTransform: type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 schemas-JavascriptTransform: type: object description: >- @@ -36697,48 +38043,48 @@ components: results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 schemas-AiTransform: type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 + properties: *ref_91 + required: *ref_92 schemas-InputTransform: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 schemas-RawScript: type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 schemas-PathScript: type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 schemas-ForloopFlow: type: object description: >- @@ -36746,160 +38092,160 @@ components: 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 schemas-BranchOne: type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 schemas-BranchAll: type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_593 - required: *ref_594 + properties: *ref_594 + required: *ref_595 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_595 - required: *ref_596 + properties: *ref_596 + required: *ref_597 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 MemoryOff: type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 MemoryAuto: type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_597 - required: *ref_598 + properties: *ref_598 + required: *ref_599 MemoryManual: type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_599 - discriminator: *ref_600 + oneOf: *ref_600 + discriminator: *ref_601 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_601 - required: *ref_602 + properties: *ref_602 + required: *ref_603 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 FlowModuleTool: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_603 + allOf: *ref_604 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_604 - required: *ref_605 + properties: *ref_605 + required: *ref_606 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_606 - required: *ref_607 + properties: *ref_607 + required: *ref_608 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_608 - discriminator: *ref_609 + oneOf: *ref_609 + discriminator: *ref_610 AgentTool: type: object description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 schemas-AiAgent: type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 schemas-StopAfterIf: type: object description: Early termination condition for a module - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_315 + properties: *ref_320 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 FlowGroup: type: object description: >- @@ -36908,16 +38254,16 @@ components: flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 + properties: *ref_151 + required: *ref_152 schemas-FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_610 - required: *ref_611 + properties: *ref_611 + required: *ref_612 schemas-FlowStatusModule: type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 535f5982d3..2980879e9f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5355,13 +5355,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: variable content: application/json: schema: - $ref: "#/components/schemas/ListableVariable" + allOf: + - $ref: "#/components/schemas/ListableVariable" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/variables/get_value/{path}: get: @@ -5445,6 +5448,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: "200": description: variable list @@ -6678,13 +6690,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: resource content: application/json: schema: - $ref: "#/components/schemas/Resource" + allOf: + - $ref: "#/components/schemas/ListableResource" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/resources/get_value_interpolated/{path}: get: @@ -6825,6 +6840,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -7813,7 +7837,40 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: @@ -7833,43 +7890,43 @@ paths: items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: list every draft the current user has in this workspace, across all kinds + operationId: listDrafts tags: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: ["flow", "script", "app"] - value: {} - required: - - path - - typ - - enum responses: - "201": - description: draft created + "200": + description: the user's drafts content: - text/plain: + application/json: schema: - type: string + type: array + items: + type: object + properties: + kind: + $ref: "#/components/schemas/UserDraftItemKind" + path: + type: string + summary: + type: string + description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. + draft_only: + type: boolean + description: No deployed counterpart exists at this path — the draft is the whole item. + created_at: + type: string + format: date-time + required: [kind, path, draft_only, created_at] - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: fetch a single draft's content by workspace username (or the legacy workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -7878,19 +7935,76 @@ paths: in: path required: true schema: - type: string - enum: - - script - - flow - - app + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" + - name: username + in: query + required: false + description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row. + schema: + type: string responses: "200": - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: [value, created_at] + "404": + description: no draft for that owner at that path + + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: Server timestamp of the client's last known sync for this draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + "200": + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [saved, conflict] + current_timestamp: + type: string + format: date-time + required: [status, current_timestamp] /w/{workspace}/scripts/create: post: @@ -8269,13 +8383,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: script details content: application/json: schema: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/scripts/get_triggers_count/{path}: get: @@ -8313,23 +8430,6 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: script details - content: - application/json: - schema: - $ref: "#/components/schemas/NewScriptWithDraft" - /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -9505,10 +9605,40 @@ paths: - $ref: "#/components/schemas/Flow" - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: @@ -9638,13 +9768,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: flow details content: application/json: schema: - $ref: "#/components/schemas/Flow" + allOf: + - $ref: "#/components/schemas/Flow" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/flows/deployment_status/p/{path}: get: @@ -9732,32 +9865,6 @@ paths: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: flow details with draft - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Flow" - - type: object - properties: - draft: - $ref: "#/components/schemas/Flow" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -9805,8 +9912,6 @@ paths: - $ref: "#/components/schemas/OpenFlowWPath" - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -10068,6 +10173,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: All raw apps @@ -10341,8 +10447,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10396,8 +10500,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10459,13 +10561,24 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: "200": description: app details content: application/json: schema: - $ref: "#/components/schemas/AppWithLastVersion" + allOf: + - $ref: "#/components/schemas/AppWithLastVersion" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/apps/get/lite/{path}: get: @@ -10484,23 +10597,6 @@ paths: schema: $ref: "#/components/schemas/AppWithLastVersion" - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: app details with draft - content: - application/json: - schema: - $ref: "#/components/schemas/AppWithLastVersionWDraft" - /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -13657,13 +13753,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: schedule deleted content: application/json: schema: - $ref: "#/components/schemas/Schedule" + allOf: + - $ref: "#/components/schemas/Schedule" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/schedules/exists/{path}: get: @@ -13735,6 +13834,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: schedule list @@ -13946,13 +14054,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: http trigger deleted content: application/json: schema: - $ref: "#/components/schemas/HttpTrigger" + allOf: + - $ref: "#/components/schemas/HttpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/http_triggers/list: get: @@ -13984,6 +14095,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: http trigger list @@ -14152,13 +14264,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: websocket trigger deleted content: application/json: schema: - $ref: "#/components/schemas/WebsocketTrigger" + allOf: + - $ref: "#/components/schemas/WebsocketTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/websocket_triggers/list: get: @@ -14190,6 +14305,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: websocket trigger list @@ -14357,13 +14473,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: kafka trigger deleted content: application/json: schema: - $ref: "#/components/schemas/KafkaTrigger" + allOf: + - $ref: "#/components/schemas/KafkaTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/kafka_triggers/list: get: @@ -14395,6 +14514,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: kafka trigger list @@ -14603,13 +14723,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: nats trigger deleted content: application/json: schema: - $ref: "#/components/schemas/NatsTrigger" + allOf: + - $ref: "#/components/schemas/NatsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/nats_triggers/list: get: @@ -14641,6 +14764,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: nats trigger list @@ -14803,13 +14927,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: sqs trigger deleted content: application/json: schema: - $ref: "#/components/schemas/SqsTrigger" + allOf: + - $ref: "#/components/schemas/SqsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/sqs_triggers/list: get: @@ -14841,6 +14968,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: sqs trigger list @@ -15296,6 +15424,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: native triggers list @@ -15596,13 +15725,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: mqtt trigger deleted content: application/json: schema: - $ref: "#/components/schemas/MqttTrigger" + allOf: + - $ref: "#/components/schemas/MqttTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/mqtt_triggers/list: get: @@ -15634,6 +15766,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: mqtt trigger list @@ -15796,13 +15929,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: gcp trigger deleted content: application/json: schema: - $ref: "#/components/schemas/GcpTrigger" + allOf: + - $ref: "#/components/schemas/GcpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/gcp_triggers/list: get: @@ -15834,6 +15970,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: gcp trigger list @@ -16063,13 +16200,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: azure trigger content: application/json: schema: - $ref: "#/components/schemas/AzureTrigger" + allOf: + - $ref: "#/components/schemas/AzureTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/azure_triggers/list: get: @@ -16094,6 +16234,7 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: azure trigger list @@ -16606,13 +16747,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: get postgres trigger content: application/json: schema: - $ref: "#/components/schemas/PostgresTrigger" + allOf: + - $ref: "#/components/schemas/PostgresTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/postgres_triggers/list: get: @@ -16644,6 +16788,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: postgres trigger list @@ -16806,13 +16951,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: email trigger retrieved content: application/json: schema: - $ref: "#/components/schemas/EmailTrigger" + allOf: + - $ref: "#/components/schemas/EmailTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/email_triggers/list: get: @@ -16844,6 +16992,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: email trigger list @@ -20569,6 +20718,25 @@ components: name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: When true, overlay the authed user's draft (if any) onto the deployed payload. + schema: + type: boolean + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: + type: boolean Id: name: id in: path @@ -21000,6 +21168,81 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: | + Other workspace users (and the legacy NULL-email row, if any) + with a saved draft at the same path. Populated only on the + authed user's "get by path" responses for kinds the editor + surfaces a fork banner for (script, flow, app, raw_app). + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: | + Workspace username of the draft owner. `null` represents + the legacy workspace-level (NULL-email) row. Emails never + leave the server. + required: [is_draft] + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: + - script + - flow + - app + - raw_app + - resource + - variable + - trigger_schedule + - trigger_webhook + - trigger_default_email + - trigger_email + - trigger_http + - trigger_websocket + - trigger_postgres + - trigger_kafka + - trigger_nats + - trigger_mqtt + - trigger_sqs + - trigger_gcp + - trigger_azure + - trigger_poll + - trigger_cli + - trigger_nextcloud + - trigger_google + - trigger_github OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: @@ -21822,8 +22065,6 @@ components: type: boolean tag: type: string - has_draft: - type: boolean draft_only: type: boolean envs: @@ -21936,8 +22177,6 @@ components: enum: [script, failure, trigger, command, approval, preprocessor] tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -22030,22 +22269,6 @@ components: - content - language - NewScriptWithDraft: - allOf: - - $ref: "#/components/schemas/NewScript" - - type: object - properties: - draft: - $ref: "#/components/schemas/NewScript" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - hash: - type: string - required: - - hash - ScriptHistory: type: object properties: @@ -23120,6 +23343,17 @@ components: format: date-time edited_by: type: string + draft_only: + description: | + True when this row is a per-user draft with no deployed + variable at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - workspace_id - path @@ -23717,6 +23951,17 @@ components: Read-only — edit them on the folder. ws_specific: type: boolean + draft_only: + description: | + True when this row is a per-user draft with no deployed + resource at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - resource_type @@ -23873,6 +24118,17 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + schedule at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean inherited_labels: type: array items: @@ -24194,6 +24450,19 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + trigger at the same path. Set by list endpoints when + `include_draft_only=true` synthesizes the row from the + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - script_path @@ -27030,6 +27299,34 @@ components: items: type: string default: [] + is_draft: + type: boolean + description: | + True when the authed user has a draft for this app — either no + deployed row exists at this path (draft-only) or the user has + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not yet deployed. + Sourced from the draft JSON's `draft_path` field (the editor + only writes it when the typed path differs from the deployed + one). Lets the home list render the meaningful name instead of + the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and the legacy + NULL-email row if any) who have a per-user draft at this + path. Drives the home page's user-avatar circles inside the + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true inherited_labels: type: array items: @@ -27178,19 +27475,6 @@ components: - raw_app - AppWithLastVersionWDraft: - allOf: - - $ref: "#/components/schemas/AppWithLastVersion" - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - AppHistory: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4a245530e5..029f0e7d1f 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -12,7 +12,7 @@ use crate::{ db::{ApiAuthed, DB}, jobs::RunJobQuery, users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed}, - utils::{check_scopes, WithStarredInfoQuery}, + utils::check_scopes, webhook_util::{WebhookMessage, WebhookShared}, HTTP_CLIENT, }; @@ -58,6 +58,7 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -90,7 +91,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { .route("/list_search", get(list_search_apps)) .route("/get/p/{*path}", get(get_app)) .route("/get/lite/{*path}", get(get_app_lite)) - .route("/get/draft/{*path}", get(get_app_w_draft)) .route("/secret_of/{*path}", get(get_secret_id)) .route( "/secret_of_latest_version/{*path}", @@ -155,7 +155,9 @@ pub struct ListableApp { pub execution_mode: String, pub starred: bool, pub edited_at: Option>, - pub has_draft: bool, + /// `Some(true)` only on rows synthesised from the `draft` table; `None` for + /// deployed rows. See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[sqlx(default)] @@ -165,6 +167,20 @@ pub struct ListableApp { pub raw_app: bool, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this app (draft-only or layered + /// over the deployed row). See ListableScript in windmill-types/src/scripts.rs. + #[serde(default, skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -214,20 +230,6 @@ pub struct AppWithLastVersionAndStarred { pub starred: Option, } -#[derive(Serialize, Deserialize, FromRow)] -pub struct AppWithLastVersionAndDraft { - #[sqlx(flatten)] - #[serde(flatten)] - pub app: AppWithLastVersion, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, -} - #[derive(Serialize)] pub struct AppHistory { pub app_id: i64, @@ -306,7 +308,6 @@ pub struct CreateApp { pub summary: String, pub value: sqlx::types::Json>, pub policy: Policy, - pub draft_only: Option, pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, @@ -370,6 +371,7 @@ async fn list_search_apps( async fn list_apps( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -387,10 +389,15 @@ async fn list_apps( "app_version.created_at as edited_at", "app.extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "app_version.raw_app", "app.labels", + "draft.path IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ = 'app') as draft_users", "folder_labels(app.workspace_id, app.path) as inherited_labels", ]) .left() @@ -400,15 +407,19 @@ async fn list_apps( .bind(&authed.username), ) .left() + // `app`/`raw_app` are separate draft kinds over one `app` table — match either + // for `is_draft`. DISTINCT in the subquery: a path with both kinds for the same + // user would otherwise fan the deployed row into two identical entries. + .join( + "(SELECT DISTINCT path, workspace_id FROM draft WHERE typ IN ('app', 'raw_app') AND email = ?) draft" + .bind(&authed.email), + ) + .on("draft.path = app.path AND draft.workspace_id = app.workspace_id") + .left() .join("app_version") .on( "app_version.id = versions[array_upper(versions, 1)]" ) - .left() - .join("draft") - .on( - "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'" - ) .order_desc("favorite.path IS NOT NULL") .order_by("app_version.created_at", true) .and_where("app.workspace_id = ?".bind(&w_id)) @@ -428,10 +439,6 @@ async fn list_apps( sqlb.and_where_eq("app.path", "?".bind(path_exact)); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("app.draft_only IS NOT TRUE"); - } - if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( @@ -451,12 +458,87 @@ async fn list_apps( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableApp>(&sql) + let mut rows = sqlx::query_as::<_, ListableApp>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; + // see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + { + // DISTINCT ON (path), newest first: collapse a path holding both `app` and + // `raw_app` drafts to one row (the home list keyed by `type/path` would crash + // on duplicates). `(email IS NULL)` last keeps the owned row over the legacy one. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at, + typ::text as "typ!" + FROM draft + WHERE workspace_id = $1 + AND typ IN ('app', 'raw_app') + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM app a + WHERE a.workspace_id = draft.workspace_id + AND a.path = draft.path + ) + ORDER BY path, (email IS NULL), created_at DESC"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // App/raw-app drafts are the bare editor value with no `path`, so the editor + // writes a separate `draft_path` only when it differs from deployed; see flows.rs. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableApp { + id: 0, + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + version: 0, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + execution_mode: String::new(), + starred: false, + edited_at: Some(row.created_at), + draft_only: Some(true), + deployment_msg: None, + raw_app: row.typ == "raw_app", + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -578,12 +660,25 @@ async fn get_raw_app_data( // Ok(Json(version)) // } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetAppQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, + /// Picks the draft kind for a draft-only lookup (`/apps_raw/...` → true). + /// Ignored when a deployed row exists — its own `raw_app` column wins. + #[serde(default)] + raw_app: Option, +} + async fn get_app( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -597,9 +692,9 @@ async fn get_app( JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] LEFT JOIN favorite - ON favorite.favorite_kind = 'app' - AND favorite.workspace_id = app.workspace_id - AND favorite.path = app.path + ON favorite.favorite_kind = 'app' + AND favorite.workspace_id = app.workspace_id + AND favorite.path = app.path AND favorite.usr = $3 WHERE app.path = $1 AND app.workspace_id = $2", ) @@ -623,8 +718,27 @@ async fn get_app( }; tx.commit().await?; - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + // Draft kind comes from the deployed row's `raw_app` flag, or for a draft-only + // path from the caller's `raw_app` query param. + let kind = match &app_o { + Some(app) if app.app.raw_app => UserDraftItemKind::RawApp, + Some(_) => UserDraftItemKind::App, + None if query.raw_app.unwrap_or(false) => UserDraftItemKind::RawApp, + None => UserDraftItemKind::App, + }; + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + kind, + path, + query.get_draft, + app_o, + || windmill_common::error::Error::NotFound(format!("App not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn get_app_lite( @@ -655,55 +769,6 @@ async fn get_app_lite( Ok(Json(app)) } -async fn get_app_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("apps:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>( - r#" - SELECT - app.id, - app.path, - app.summary, - app.versions, - app.policy, - app.custom_path, - app.extra_perms, - app_version.value, - app_version.created_at, - app_version.created_by, - app.draft_only, - draft.value AS "draft", - draft.created_at AS "draft_created_at", - app_version.raw_app, - app.labels - FROM app - INNER JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - LEFT JOIN draft - ON app.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'app' - WHERE app.path = $1 - AND app.workspace_id = $2 - "#, - ) - .bind(path.to_owned()) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) -} - async fn get_app_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -1374,25 +1439,27 @@ async fn create_app_internal<'a>( } } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !app.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", &app.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; } let id = sqlx::query_scalar!( "INSERT INTO app - (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) - VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", + (workspace_id, path, summary, policy, versions, custom_path, labels) + VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", w_id, app.path, app.summary, json!(app.policy), - app.draft_only, app.custom_path .as_ref() .map(|s| if s.is_empty() { None } else { Some(s) }) @@ -1598,15 +1665,16 @@ async fn delete_app( .await?; let trash_drafts: Vec = sqlx::query_scalar( - "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; + // Both `app` and `raw_app` draft kinds back the same `app` table. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", path, &w_id ) @@ -1827,7 +1895,6 @@ async fn update_app_internal<'a>( sqlb.and_where_eq("path", "?".bind(&path)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); - sqlb.set("draft_only", "NULL"); if let Some(npath) = &ns.path { if npath != path { require_owner_of_path(&authed, path)?; @@ -2014,12 +2081,15 @@ async fn update_app_internal<'a>( } }; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row) — see create_app_internal. if !ns.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -3281,18 +3351,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { Ok((permissioned_as, email)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2", - "app", - ) - .await; -} - async fn exists_app( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b0cd5f61b0..6ab1a296c5 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -6,133 +6,502 @@ * LICENSE-AGPL for a copy of the license. */ -use crate::{ - db::{ApiAuthed, DB}, - users::{maybe_refresh_folders, require_owner_of_path}, -}; +use crate::db::{ApiAuthed, DB}; use axum::{ extract::{Extension, Path}, - routing::{delete, post}, + routing::{get, post}, Json, Router, }; -use hyper::StatusCode; use serde::{Deserialize, Serialize}; -use windmill_common::{db::UserDB, error::Result, utils::StripPath}; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + variables::{build_crypt, encrypt}, +}; pub fn workspaced_service() -> Router { Router::new() - .route("/create", post(create_draft)) - .route("/delete/{kind}/{*path}", delete(delete_draft)) + .route("/list", get(list_drafts)) + .route("/get/{kind}/{*path}", get(get_draft_for_user)) + .route("/update/{kind}/{*path}", post(update_draft)) } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] -#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")] -#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] -pub enum DraftType { - Script, - Flow, - App, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct Draft { +#[derive(Serialize, sqlx::FromRow)] +pub struct DraftListItem { + pub kind: UserDraftItemKind, pub path: String, - pub value: sqlx::types::Json>, - pub typ: DraftType, + /// Best-effort, read from the draft JSON's `summary` field when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// No deployed counterpart exists at this path — the draft is the whole + /// item. Kinds without a per-path backing table report `true`. + pub draft_only: bool, + pub created_at: chrono::DateTime, } -pub async fn require_writer_of_path( - authed: &ApiAuthed, - path: &str, - w_id: &str, - db: DB, - kind: &DraftType, -) -> Result<()> { - if authed.is_admin { - return Ok(()); - } else if require_owner_of_path(authed, path).is_ok() { - return Ok(()); +/// Every draft the authed user has in this workspace, across all kinds — the +/// single source for the "Review & deploy drafts" page and the home-page +/// draft-count banner. One query over `draft`; `draft_only` is computed per +/// kind against the deployed table. +async fn list_drafts( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result>> { + // Operators have no drafts of their own (they can't write any, see + // `require_can_write_path`), so this list is always empty for them. They + // can still READ some collaborators' drafts via `/drafts/get`. + if authed.is_operator { + return Ok(Json(vec![])); + } + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + .bind(&w_id) + .bind(&authed.email) + .fetch_all(&db) + .await?; + Ok(Json(rows)) +} + +/// Build the `list_drafts` SQL, generating the `draft_only` CASE from +/// `deployed_table()` (shared single source — can't drift from the access +/// check). Table names come from the closed enum, never user input. Kinds +/// with no path-keyed table get no arm and fall to `ELSE true`. +/// `$1` = workspace_id, `$2` = email. +fn list_drafts_query() -> String { + let mut case = String::from("CASE d.typ::text\n"); + for kind in UserDraftItemKind::ALL { + let Some(table) = kind.deployed_table() else { + continue; + }; + // `script` rows are soft-deleted — a deleted script counts as "not + // deployed". No other backing table has a `deleted` flag. + let extra = if matches!(kind, UserDraftItemKind::Script) { + " AND t.deleted = false" + } else { + "" + }; + case.push_str(&format!( + " WHEN '{}' THEN NOT EXISTS(SELECT 1 FROM {} t WHERE t.workspace_id = d.workspace_id AND t.path = d.path{})\n", + kind.as_str(), + table, + extra + )); + } + case.push_str(" ELSE true\nEND"); + // `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the + // legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL` + // last collapses a (path, kind) that has both to the owned row. + format!( + r#"SELECT DISTINCT ON (d.path, d.typ) + d.path, + d.typ AS kind, + d.created_at, + d.value ->> 'summary' AS summary, + {case} AS draft_only + FROM draft d + WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) + ORDER BY d.path, d.typ, (d.email IS NULL)"# + ) +} + +#[derive(Deserialize, Debug)] +pub struct SaveDraftRequest { + /// Draft content to save. `null` (or omitted) signals a delete — the + /// row is removed under the same conflict rules as an upsert. + #[serde(default)] + pub value: Option>>, + /// Client's last known sync timestamp. When present and `force` is false, + /// the save is rejected if the server's `created_at` is more recent + /// (another writer moved the row forward). Omit on a first save. + #[serde(default)] + pub last_sync: Option>, + /// Skip the conflict check and unconditionally overwrite the server + /// copy. Use after the client has resolved the conflict locally. + #[serde(default)] + pub force: bool, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SaveDraftStatus { + Saved, + Conflict, +} + +#[derive(Serialize, Debug)] +pub struct SaveDraftResponse { + pub status: SaveDraftStatus, + /// On `saved`: when the change was applied (client remembers it as the + /// next `last_sync`). On `conflict`: the existing row's `created_at`. + pub current_timestamp: chrono::DateTime, +} + +/// Apply the current user's draft at (workspace, kind, path): non-null `value` +/// upserts, `null` (or omitted) deletes. Either way, when the existing row is +/// newer than `last_sync` (and `force` is false) the op is skipped and the +/// response is `status = conflict` + the server's current timestamp. +async fn update_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result> { + let email = &authed.email; + let path = path.to_path(); + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + + let applied_at = if let Some(value) = &req.value { + // Secret variable values must never sit in `draft.value` in plaintext + // (see `encrypt_secret_variable_value`). + let serialized = if kind == UserDraftItemKind::Variable { + encrypt_secret_variable_value(&db, &w_id, value.0.get()).await? + } else { + serde_json::to_string(value).unwrap() + }; + // Upsert. The conflict check rides on the DO UPDATE WHERE clause — + // when the row is newer than `last_sync`, RETURNING yields nothing. + sqlx::query_scalar!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) + VALUES ($1, $2, $3, $4, $5::text::json, now()) + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = now() + WHERE $7::bool = true + OR $6::timestamptz IS NULL + OR draft.created_at <= $6::timestamptz + RETURNING created_at"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + serialized, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? } else { - match kind { - DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await, - DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await, - DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await, + // Delete, same conflict rule in the WHERE clause. Returns NULL when + // the row was too new (conflict) OR already absent (idempotent) — + // disambiguated below. + sqlx::query_scalar!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND email = $2 + AND path = $3 + AND typ = $4 + AND ($6::bool = true + OR $5::timestamptz IS NULL + OR created_at <= $5::timestamptz) + RETURNING now() as "now!""#, + &w_id, + email, + path, + kind as UserDraftItemKind, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? + }; + + if let Some(ts) = applied_at { + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: ts, + })); + } + + // No row affected: either the row was newer than `last_sync` (conflict), + // or it was a delete with no row present (idempotent ok). Distinguished + // by re-reading. + let existing = sqlx::query_scalar!( + r#"SELECT created_at FROM draft + WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(&db) + .await?; + + match existing { + Some(ts) => Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Conflict, + current_timestamp: ts, + })), + // Delete + nothing-was-there ⇒ report success with server's NOW(). + None => { + let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) + .fetch_one(&db) + .await?; + Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: now, + })) } } } -async fn create_draft( +/// For variable-kind drafts with `variable.is_secret == true`, encrypt +/// `variable.value` with the workspace crypt key and mark it +/// `$encrypted:` so the secret never persists in plaintext at rest. +/// Already-marked values pass through untouched. Unexpected/malformed shapes +/// pass through unchanged — the draft store is schema-less by design. +async fn encrypt_secret_variable_value(db: &DB, w_id: &str, raw: &str) -> Result { + let Ok(mut v) = serde_json::from_str::(raw) else { + return Ok(raw.to_string()); + }; + let is_secret = v + .get("variable") + .and_then(|x| x.get("is_secret")) + .and_then(|x| x.as_bool()) + .unwrap_or(false); + if is_secret { + if let Some(serde_json::Value::String(s)) = + v.get_mut("variable").and_then(|x| x.get_mut("value")) + { + if !s.is_empty() && !s.starts_with(ENCRYPTED_DRAFT_PREFIX) { + let mc = build_crypt(db, w_id).await?; + *s = format!("{ENCRYPTED_DRAFT_PREFIX}{}", encrypt(&mc, s)); + } + } + } + Ok(v.to_string()) +} + +#[derive(Deserialize, Debug)] +pub struct GetDraftQuery { + /// Workspace username of the draft owner. Omit to fetch the legacy + /// NULL-email row, if any. Resolved to an email server-side — emails are + /// not part of the public draft API. + pub username: Option, +} + +#[derive(Serialize, Debug)] +pub struct DraftForUser { + pub value: sqlx::types::Json>, + pub created_at: chrono::DateTime, +} + +/// Fetch a specific user's (or the legacy NULL row's) draft content at a path. +/// Backs the "other users' drafts" banner in editors. The owner is identified +/// by workspace username so emails never reach the client. +async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path(w_id): Path, - Json(draft): Json, -) -> Result<(StatusCode, String)> { - let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await; + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + let path = path.to_path(); + // Drawer kinds keep drafts private to their owner (see + // `shares_drafts_across_users`) — also what blocks reading another user's + // secret-variable `$encrypted:` ciphertext. + if !kind.shares_drafts_across_users() { + return Err(Error::NotFound( + "drafts for this item kind are private to their owner".to_string(), + )); + } + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; - let mut tx = user_db.begin(&authed).await?; + // Username -> email, scoped to the workspace. None signals "fetch the + // legacy NULL-email row" (distinct from a username with no draft, which + // 404s below). + let owner_email: Option = if let Some(username) = &query.username { + let email = sqlx::query_scalar!( + r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#, + &w_id, + username, + ) + .fetch_optional(&db) + .await?; + match email { + Some(e) => Some(e), + // The `admins` workspace has no `usr` rows (username IS the email + // there), so accept it as the owner email directly. + None if w_id == "admins" => Some(username.clone()), + None => { + return Err(Error::NotFound(format!( + "no user with username {username} in workspace" + ))) + } + } + } else { + None + }; - require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?; - - sqlx::query!( - "INSERT INTO draft - (workspace_id, path, value, typ) - VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) - DO UPDATE SET value = EXCLUDED.value, created_at = now()", + let row = sqlx::query_as!( + DraftForUser, + r#"SELECT value as "value!: sqlx::types::Json>", created_at + FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND email IS NOT DISTINCT FROM $4"#, &w_id, - draft.path, - //to preserve key orders - serde_json::to_string(&draft.value).unwrap(), - draft.typ as DraftType, + path, + kind as UserDraftItemKind, + owner_email, ) - .execute(&mut *tx) + .fetch_optional(&db) .await?; - tx.commit().await?; - - Ok((StatusCode::CREATED, format!("draft {} created", draft.path))) + row.map(Json).ok_or_else(|| { + Error::NotFound(format!( + "no draft for {} at {path}", + query.username.as_deref().unwrap_or("") + )) + }) } -async fn delete_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - path.to_path(), - kind as DraftType, - w_id - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - Ok(format!("deleted draft")) +/// The deployed table RLS resolves item-level `extra_perms` against. +/// Delegates to `UserDraftItemKind::deployed_table()` (the shared single +/// source); `None` kinds fall through to the path-only access check. +fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { + kind.deployed_table() } -// async fn get_draft( -// authed: ApiAuthed, -// Extension(user_db): Extension, -// Path((w_id, path)): Path<(String, StripPath)>, -// ) -> JsonResult { -// let path = path.to_path(); -// let mut tx = user_db.begin(&authed).await?; +/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are +/// rejected outright. Two layers: +/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable +/// `f/`) — mirror what RLS reads from the same JWT claims, and are the +/// ENTIRE check for draft-only paths (no deployed row for RLS to use). +/// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for +/// what the path can't answer, above all item-level extra_perms grants. +async fn require_can_write_path( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + // Operators are read-only and never WRITE drafts. Read access is + // deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, + // mirroring their read access to deployed content. Intended. + if authed.is_operator { + return Err(Error::NotAuthorized( + "operators cannot save drafts".to_string(), + )); + } + // Cheap claim-based namespace checks first: they evaluate the same JWT + // claims RLS reads, so the outcome matches the policies while sparing the + // autosave hot path a DB round-trip. They are also the ENTIRE check for + // draft-only paths (no deployed row for RLS) — without them any member + // could plant a draft in another user's `u/` namespace, surfaced to every + // reader of the path. `require_owner_of_path` covers admin / `u/{own}` / + // folder owner; group membership and the folder WRITE bit are layered on. + if windmill_api_auth::require_owner_of_path(authed, path).is_ok() { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 3 { + match parts[0] { + "g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()), + "f" => { + let folder = parts[1]; + let has_write = |a: &ApiAuthed| { + a.folders + .iter() + .any(|(name, write, owner)| name == folder && (*write || *owner)) + }; + if has_write(authed) { + return Ok(()); + } + let refreshed = + windmill_api_auth::maybe_refresh_folders(path, w_id, authed.clone(), db).await; + if has_write(&refreshed) { + return Ok(()); + } + } + _ => {} + } + } + // Defer to RLS for what the path can't answer (item-level extra_perms + // grants). Postgres applies UPDATE policies to rows locked via `SELECT + // ... FOR UPDATE`, so a returned row means the canonical write policies + // would let this user UPDATE it — no rule re-implemented here. Draft-only + // paths have no row, so the namespace rules above were the whole check. + if let Some(table) = kind.deployed_table() { + // `table` is from the closed enum, never user input. LIMIT 1 keeps the + // probe to one row lock — `script` has a row per version at the path, + // and locking the whole history would serialize against deploys. + let query = format!( + "SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1 FOR UPDATE" + ); + let mut tx = user_db.clone().begin(authed).await?; + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotAuthorized(format!( + "you don't have write permission on {path}" + ))) +} -// let script_o = sqlx::query_as!( -// Draft, -// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#, -// path, -// w_id -// ) -// .fetch_optional(&mut *tx) -// .await?; -// tx.commit().await?; - -// let draft = not_found_if_none(script_o, "draft", path)?; -// Ok(Json(draft)) -// } +/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers: +/// 1. admin → always. +/// 2. Path-prefix match against own `u/{username}` or any folder in +/// `authed.folders` (the precomputed read set, with groups + direct +/// grants already factored in). +/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level +/// extra_perms grants that bypass folder/owner checks. +/// Both "not readable" and "doesn't exist" return 404 — don't leak existence. +/// +/// Operators are deliberately NOT rejected here (unlike +/// `require_can_write_path`): read-only users keep their read access to +/// deployed content, so an operator can view a collaborator's draft for the +/// cross-user kinds they can already read, while never writing one. Drawer +/// kinds never reach this (`get_draft_for_user` rejects them up front). +async fn require_can_read_path( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 2 { + match parts[0] { + "u" if parts[1] == authed.username => return Ok(()), + "f" => { + let folder = parts[1]; + if authed.folders.iter().any(|(name, _, _)| name == folder) { + return Ok(()); + } + } + _ => {} + } + } + if let Some(table) = table_for_kind(kind) { + let mut tx = user_db.clone().begin(authed).await?; + let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1"); + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotFound(format!("no draft visible at {path}"))) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 787e0ceb93..0f5f9c40ca 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -77,8 +77,8 @@ mod capture; mod concurrency_groups; mod db; mod db_health; - mod drafts; + #[cfg(feature = "private")] pub mod ee; pub mod ee_oss; @@ -553,8 +553,8 @@ pub async fn run_server( "/concurrency_groups", concurrency_groups::workspaced_service(), ) - .nest("/embeddings", embeddings::workspaced_service()) .nest("/drafts", drafts::workspaced_service()) + .nest("/embeddings", embeddings::workspaced_service()) .nest("/favorites", favorite::workspaced_service()) .nest("/flows", flows::workspaced_service()) .nest( diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index c7a1b291dd..4073758ba9 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -41,7 +41,6 @@ pub async fn get_item_schema( sqlb.and_where("o.path = ?".bind(&path)); sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)); sqlb.and_where("o.archived = false"); - sqlb.and_where("o.draft_only IS NOT TRUE"); let sql = sqlb.sql().map_err(|e| { tracing::error!("failed to build sql: {}", e); ErrorData::internal_error(format!("failed to build sql: {}", e), None) @@ -147,8 +146,7 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen .bind(&authed.username)); } sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)) - .and_where("o.archived = false") - .and_where("o.draft_only IS NOT TRUE"); + .and_where("o.archived = false"); if item_type == "script" { sqlb.and_where("o.auto_kind IS NULL"); diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index c929a85a84..3d5342e3a5 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -847,8 +847,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 56bfd62ce5..271e9497fe 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -70,9 +70,9 @@ async fn list_paths( let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( - (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000) + (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000) UNION - (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000) + (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000) UNION (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000) UNION diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index d53998355b..0da987e675 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -457,8 +457,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index d63720d9a9..4f55877e62 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -13,7 +13,6 @@ pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_adm #[cfg(feature = "private")] pub use windmill_common::usernames::generate_instance_wide_unique_username; -pub use windmill_common::utils::WithStarredInfoQuery; #[cfg(feature = "enterprise")] pub use windmill_alerting::{ diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index afb39e2cca..dae1be0614 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -367,7 +367,6 @@ where "edited_by", "permissioned_as", "archived", - "has_draft", "error", "last_server_ping", "server_id", @@ -650,7 +649,6 @@ pub(crate) async fn tarball_workspace( { let scripts = sqlx::query_as::<_, Script>(&format!( "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false - AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", windmill_common::scripts::SCRIPT_COLUMNS, @@ -786,10 +784,10 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)", + WHERE flow.workspace_id = $1 AND flow.archived = false", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -838,8 +836,7 @@ pub(crate) async fn tarball_workspace( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, app.extra_perms, app_version.value, app_version.created_at, app_version.created_by, app_version.raw_app, app.labels from app, app_version - WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)] - AND (app.draft_only IS NULL OR app.draft_only = false)", + WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -918,7 +915,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::http::HttpTrigger; let handler = HttpTrigger; - let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let http_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -948,7 +945,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::websocket::WebsocketTrigger; let handler = WebsocketTrigger; - let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -978,7 +975,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::kafka::KafkaTrigger; let handler = KafkaTrigger; - let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1008,7 +1005,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::sqs::SqsTrigger; let handler = SqsTrigger; - let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1038,7 +1035,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::gcp::GcpTrigger; let handler = GcpTrigger; - let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1068,7 +1065,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::azure::AzureTrigger; let handler = AzureTrigger; - let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1098,7 +1095,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::nats::NatsTrigger; let handler = NatsTrigger; - let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1128,7 +1125,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::postgres::PostgresTrigger; let handler = PostgresTrigger; - let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1158,7 +1155,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::mqtt::MqttTrigger; let handler = MqttTrigger; - let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1188,7 +1185,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::email::EmailTrigger; let handler = EmailTrigger; - let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let email_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 88a7706b88..7e30547d9b 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -38,6 +38,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true serde_yml.workspace = true +erased-serde = "0.4" chrono.workspace = true chrono-tz.workspace = true hex.workspace = true diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index c12d242737..ba42f13d80 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -102,6 +102,7 @@ pub mod teams_oss; pub mod tracing_init; pub mod trashbin; pub mod triggers; +pub mod user_drafts; pub mod usernames; pub mod users; pub mod utils; diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 366ed75e8e..0cbcb4a1c1 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -97,7 +97,6 @@ pub async fn prefetch_cached_script( language: script.language, kind: script.kind, tag: script.tag, - draft_only: script.draft_only, envs: script.envs, dedicated_worker: script.dedicated_worker, ws_error_handler_muted: script.ws_error_handler_muted, @@ -363,7 +362,6 @@ pub async fn clone_script<'c>( language: s.language, kind: Some(s.kind), tag: s.tag, - draft_only: s.draft_only, envs: s.envs, concurrency_settings: concurrency_settings.maybe_fallback( s.runnable_settings.concurrency_key, @@ -410,14 +408,14 @@ pub async fn clone_script<'c>( INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, \ created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs new file mode 100644 index 0000000000..e008ccc658 --- /dev/null +++ b/backend/windmill-common/src/user_drafts.rs @@ -0,0 +1,536 @@ +/* + * Author: Diego Imbert + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Shared types and helpers for the per-user `draft` table. Lives in +//! `windmill-common` so entity crates can use it without depending on the +//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns. + +// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row +// structs `ListableScript`/`ListableFlow` declare `Vec` and +// can't reach `windmill-common` without a cycle). Re-exported here so draft +// handlers keep a single import path. +pub use windmill_types::user_drafts::DraftUserRef; + +use crate::db::DB; +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Item kinds a user can have an autosaved draft on. Must stay in lockstep +/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND` +/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration). +/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`). +#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserDraftItemKind { + Script, + Flow, + App, + RawApp, + Resource, + Variable, + TriggerSchedule, + TriggerWebhook, + TriggerDefaultEmail, + TriggerEmail, + TriggerHttp, + TriggerWebsocket, + TriggerPostgres, + TriggerKafka, + TriggerNats, + TriggerMqtt, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + +impl UserDraftItemKind { + /// The snake_case wire/DB string, for interpolating into dynamically-built + /// SQL (`?::DRAFT_KIND` binds want a string). + pub fn as_str(&self) -> &'static str { + match self { + UserDraftItemKind::Script => "script", + UserDraftItemKind::Flow => "flow", + UserDraftItemKind::App => "app", + UserDraftItemKind::RawApp => "raw_app", + UserDraftItemKind::Resource => "resource", + UserDraftItemKind::Variable => "variable", + UserDraftItemKind::TriggerSchedule => "trigger_schedule", + UserDraftItemKind::TriggerWebhook => "trigger_webhook", + UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email", + UserDraftItemKind::TriggerEmail => "trigger_email", + UserDraftItemKind::TriggerHttp => "trigger_http", + UserDraftItemKind::TriggerWebsocket => "trigger_websocket", + UserDraftItemKind::TriggerPostgres => "trigger_postgres", + UserDraftItemKind::TriggerKafka => "trigger_kafka", + UserDraftItemKind::TriggerNats => "trigger_nats", + UserDraftItemKind::TriggerMqtt => "trigger_mqtt", + UserDraftItemKind::TriggerSqs => "trigger_sqs", + UserDraftItemKind::TriggerGcp => "trigger_gcp", + UserDraftItemKind::TriggerAzure => "trigger_azure", + UserDraftItemKind::TriggerPoll => "trigger_poll", + UserDraftItemKind::TriggerCli => "trigger_cli", + UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", + UserDraftItemKind::TriggerGoogle => "trigger_google", + UserDraftItemKind::TriggerGithub => "trigger_github", + } + } + + /// Every variant, for code that must enumerate kinds (e.g. generating + /// the `draft_only` existence SQL). + pub const ALL: [UserDraftItemKind; 24] = [ + UserDraftItemKind::Script, + UserDraftItemKind::Flow, + UserDraftItemKind::App, + UserDraftItemKind::RawApp, + UserDraftItemKind::Resource, + UserDraftItemKind::Variable, + UserDraftItemKind::TriggerSchedule, + UserDraftItemKind::TriggerWebhook, + UserDraftItemKind::TriggerDefaultEmail, + UserDraftItemKind::TriggerEmail, + UserDraftItemKind::TriggerHttp, + UserDraftItemKind::TriggerWebsocket, + UserDraftItemKind::TriggerPostgres, + UserDraftItemKind::TriggerKafka, + UserDraftItemKind::TriggerNats, + UserDraftItemKind::TriggerMqtt, + UserDraftItemKind::TriggerSqs, + UserDraftItemKind::TriggerGcp, + UserDraftItemKind::TriggerAzure, + UserDraftItemKind::TriggerPoll, + UserDraftItemKind::TriggerCli, + UserDraftItemKind::TriggerNextcloud, + UserDraftItemKind::TriggerGoogle, + UserDraftItemKind::TriggerGithub, + ]; + + /// The deployed table backing this kind, keyed by `(workspace_id, path)`. + /// SINGLE SOURCE for both the draft access check (which table RLS resolves + /// item-level `extra_perms` against) and the `draft_only` existence check. + /// `None` for kinds with no per-path backing table (webhook is a property + /// of a script/flow row; native triggers are keyed by external_id, not + /// path) — callers treat that as "no deployed counterpart": `draft_only = + /// true` and a path-only access check. + pub fn deployed_table(&self) -> Option<&'static str> { + use UserDraftItemKind::*; + match self { + Script => Some("script"), + Flow => Some("flow"), + App | RawApp => Some("app"), + Resource => Some("resource"), + Variable => Some("variable"), + TriggerSchedule => Some("schedule"), + TriggerHttp => Some("http_trigger"), + TriggerWebsocket => Some("websocket_trigger"), + TriggerPostgres => Some("postgres_trigger"), + TriggerKafka => Some("kafka_trigger"), + TriggerNats => Some("nats_trigger"), + TriggerMqtt => Some("mqtt_trigger"), + TriggerSqs => Some("sqs_trigger"), + TriggerGcp => Some("gcp_trigger"), + TriggerAzure => Some("azure_trigger"), + TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), + TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle + | TriggerGithub => None, + } + } + + /// Whether OTHER users' drafts at a path are visible to a viewer (the + /// "others are editing" list, owner circles, and the `get_draft_for_user` + /// View JSON / Fork endpoint). Enabled only for the full-page editor items + /// which have the cross-user draft UI. Drawer items keep drafts private to + /// their owner: they have no such UI, and exposing a secret variable draft + /// would hand out the `$encrypted:` ciphertext, which a viewer could + /// launder into plaintext via a deploy. + pub fn shares_drafts_across_users(&self) -> bool { + use UserDraftItemKind::*; + matches!(self, Script | Flow | App | RawApp) + } +} + +/// Query-string flag accepted by every "get by path" route that supports +/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct +/// when the route has other query fields. +#[derive(Debug, Deserialize, Default)] +pub struct WithDraftQuery { + /// When true, attach the authed user's draft (if any) as a separate + /// `draft` field. Defaults to false so non-editor callers see the + /// deployed shape unchanged. + #[serde(default)] + pub get_draft: bool, +} + +/// One row of `other_drafts_users`: a draft on the same path owned by +/// someone other than the authed user. `username` is `None` for the legacy +/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry. +#[derive(Debug, Serialize)] +pub struct OtherDraftUser { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} + +/// Response wrapper: the deployed entity untouched plus the authed user's +/// draft (if any) as a sibling `draft` field, which the frontend pairs to +/// diff/restore/discard. The deployed and the draft are NEVER merged on the +/// server — the editor's saved shape can diverge arbitrarily, so any per-kind +/// translation lives in the frontend loader. `inner` is boxed-erased so a +/// possibly MB-scale deployed payload serializes in ONE pass (no +/// `serde_json::Value` round-trip) while keeping the struct non-generic. +#[derive(Serialize)] +pub struct WithDraftOverlay { + /// Deployed payload, flattened to the top level. + #[serde(flatten)] + pub inner: Box, + pub is_draft: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_saved_at: Option>, + /// True when no deployed row exists at this path: `inner` is only a + /// best-effort stand-in synthesized from the draft and only `draft` is + /// canonical. Frontend uses this to disable "diff/reset vs deployed" and + /// skip its deployed-shape parsing of `inner`. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub no_deployed: bool, + /// The user's saved draft payload (whatever shape the editor wrote). + /// Present when `get_draft=true` and a draft exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option, + /// Other users with a draft on the same path (excludes the authed user). + /// Empty list is omitted to keep the common-case response lean. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub other_drafts_users: Vec, +} + +/// List every other user (and the legacy NULL-email row, if any) with a +/// draft at `(workspace, kind, path)`. Returns usernames only — emails never +/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user +/// removed from the workspace) still surfaces with `username = None`. The +/// authed user is excluded via `email <> authed_email`; the legacy row +/// matches because `email IS NULL` fails that comparison. +async fn fetch_other_drafts_users( + db: &DB, + w_id: &str, + authed_email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // The `admins` workspace has no `usr` rows (username IS the email there), + // so fall back to `d.email` when the join misses, else a real teammate's + // draft renders as a phantom "Legacy draft". The genuine NULL-email legacy + // row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL). + let rows = sqlx::query_as!( + OtherDraftUser, + r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?" + FROM draft d + LEFT JOIN usr u + ON u.workspace_id = d.workspace_id + AND u.email = d.email + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ = $3 + AND (d.email IS NULL OR d.email <> $4) + ORDER BY d.email NULLS LAST"#, + w_id, + path, + kind as UserDraftItemKind, + authed_email, + ) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// If `get_draft` is true AND the authed user has a draft for +/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always +/// serialized into `inner` untouched. +pub async fn maybe_overlay_draft( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: T, +) -> Result +where + T: serde::Serialize + Send + 'static, +{ + // Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps) + // pass `get_draft = false` and render no overlay, so skip the `usr` join. + if !get_draft { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users: Vec::new(), + }); + } + + // Independent of the authed user's OWN draft: reset-to-deployed reloads + // still need to know who else is editing this path. Only the cross-user + // kinds surface it (see `shares_drafts_across_users`). + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + + // Prefer the user's OWN per-user draft, falling back to the legacy + // NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy + // row when an owned one exists. + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users, + }); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + + Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: false, + draft: Some(draft_json), + other_drafts_users, + }) +} + +/// One row of a "draft-only" list synthesis: a draft at `path` with no +/// deployed counterpart. `value` is the editor's saved JSON (each handler +/// maps it into its own `Listable*` shape). +#[derive(sqlx::FromRow)] +pub struct DraftOnlyListRow { + pub path: String, + pub value: sqlx::types::Json>, + pub created_at: DateTime, +} + +/// Fetch the authed user's draft rows at paths with NO deployed counterpart, +/// for synthesizing draft-only entries into a list response. Absence is +/// checked against `kind.deployed_table()` (the shared single source). +/// Returns empty for kinds with no path-keyed table. Callers keep their own +/// gating (`include_draft_only`, page 0, no filters) and row mapping. +pub async fn fetch_draft_only_list_rows( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, +) -> Result> { + let Some(table) = kind.deployed_table() else { + return Ok(Vec::new()); + }; + // `table` is from the closed `deployed_table()` enum, never user input. + // `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows + // AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL` + // last collapses a path that has both to the owned row. + let sql = format!( + "SELECT DISTINCT ON (path) path, value, created_at FROM draft \ + WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \ + AND (email = $3 OR email IS NULL) \ + AND NOT EXISTS (SELECT 1 FROM {table} t \ + WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \ + ORDER BY path, (email IS NULL)" + ); + let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql) + .bind(w_id) + .bind(kind.as_str()) + .bind(email) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// The get-by-path draft choreography, shared by every entity's "get by path" +/// route. Given the deployed entity as an `Option` (caller maps its own "not +/// found" to `None`): +/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`). +/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when +/// a draft exists, else the caller's 404 via `not_found`. +/// - `None` without `get_draft` → the caller's 404. +pub async fn overlay_or_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: Option, + not_found: impl FnOnce() -> crate::error::Error, +) -> Result { + match deployed { + Some(deployed) => { + maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await + } + None if get_draft => fetch_draft_only(db, w_id, email, kind, path) + .await? + .ok_or_else(not_found), + None => Err(not_found()), + } +} + +/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind. +/// Use when the item is DELETED outright: it's gone for everyone, so leaving +/// teammates' drafts behind would orphan them forever. Discarding one's OWN +/// draft while the item lives on goes through `update_draft` with `value: null`. +/// Idempotent on the no-draft case. +pub async fn delete_all_drafts_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3"#, + w_id, + path, + kind as UserDraftItemKind, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Discard the deploying user's OWN draft (plus the legacy NULL-email row) +/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the +/// item moved, so the draft at the old path is orphaned (no FK to cascade). +/// Teammates keep theirs and get the StaleDraftModal on their next reload. +/// Idempotent on the no-draft case. +pub async fn delete_own_draft_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, + email: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND (email = $4 OR email IS NULL)"#, + w_id, + path, + kind as UserDraftItemKind, + email, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Fetch the authed user's draft as a standalone payload, for "get by path" +/// routes when no deployed row exists but a draft might. Returns it as a +/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and +/// `no_deployed = true`. Callers must have established no deployed row exists; +/// `Ok(None)` when there's also no draft (caller should 404). +/// +/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner` +/// works); a non-object draft renders with no fields flattened. +pub async fn fetch_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + Ok(Some(WithDraftOverlay { + // Best-effort stand-in for the missing deployed — same JSON as `draft`. + inner: Box::new(draft_json.clone()), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: true, + draft: Some(draft_json), + other_drafts_users, + })) +} + +/// Marker prefix for draft secret values encrypted at rest with the workspace +/// crypt key (`build_crypt`). Written by `update_draft` for secret variables; +/// resolved back to plaintext by the variable deploy endpoints. +pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:"; + +fn draft_decrypt_error() -> crate::error::Error { + crate::error::Error::BadRequest( + "An encrypted draft secret could not be decrypted (the workspace encryption key may \ + have changed since the draft was saved). Reset the field and re-enter the secret." + .to_string(), + ) +} + +/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the +/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt +/// (e.g. the workspace key was rotated after the draft save). +pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result { + let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value); + let mc = crate::variables::build_crypt(db, w_id).await?; + crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) +} diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index faabd27142..0d42b95a5a 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -59,6 +59,19 @@ pub struct ListableVariable { pub edited_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub edited_by: Option, + /// True when this row is a per-user draft with no deployed variable + /// at the same path. Surfaced by `include_draft_only` so the frontend + /// can render a "Draft" badge and the editor can open from the draft + /// alone. `None`/omitted on rows fetched from the `variable` table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// layered over a deployed variable or a synthesized draft-only row. + /// Drives the `*` suffix on the variables page. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 85bb906715..44db0a1b68 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -43,6 +43,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{self, Error, JsonResult, Result}, get_database_url, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only, + fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, + WithDraftQuery, + }, utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath}, variables, worker::{CLOUD_HOSTED, WINDMILL_DIR}, @@ -153,6 +158,15 @@ pub struct ListableResource { pub inherited_labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the resources page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Deserialize)] @@ -185,6 +199,9 @@ pub struct ListResourceQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(Serialize, FromRow)] @@ -252,6 +269,7 @@ async fn list_resources( Query(lq): Query, Query(pagination): Query, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); @@ -276,6 +294,13 @@ async fn list_resources( "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", ]) + // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = resource.workspace_id \ + AND draft.path = resource.path AND draft.typ = 'resource' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -352,7 +377,7 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -361,6 +386,101 @@ async fn list_resources( tx.commit().await?; + // Append the authed user's draft-only resources; see scripts.rs. + // `resource_type` / `resource_type_exclude` are deliberately NOT in the bail-out + // list (the resources page always passes `resource_type_exclude`); they're applied + // per-row below against the draft JSON's `resource_type` instead. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let rt_filter: Option> = lq + .resource_type + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let rt_exclude: Option> = lq + .resource_type_exclude + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Resource) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let description = v + .get("description") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let value = v.get("args").cloned(); + let resource_type = v + .get("resource_type") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Mirror the deployed query's resource_type narrowing for the + // synthesized rows (see the gate comment above). + if let Some(ref rts) = rt_filter { + if !rts.contains(&resource_type.as_str()) { + continue; + } + } + if let Some(ref excl) = rt_exclude { + if excl.contains(&resource_type.as_str()) { + continue; + } + } + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableResource { + workspace_id: w_id.clone(), + path, + value, + description, + resource_type, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + created_by: None, + edited_at: Some(row.created_at), + is_linked: None, + is_refreshed: None, + is_oauth: None, + is_expired: None, + refresh_error: None, + account: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -369,13 +489,15 @@ async fn get_resource( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("resources:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let resource_o = sqlx::query_as!( ListableResource, + // `null::bool` columns align with the struct fields; deployed rows are never draft-only. "SELECT resource.workspace_id, resource.path, resource.value, resource.description, resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at, resource.labels, @@ -385,7 +507,9 @@ async fn get_resource( variable.path IS NOT NULL as is_linked, variable.is_oauth as \"is_oauth?\", variable.account, - ws_specific.path IS NOT NULL as ws_specific + ws_specific.path IS NOT NULL as ws_specific, + null::bool as draft_only, + null::bool as is_draft FROM resource LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2 LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 @@ -397,11 +521,30 @@ async fn get_resource( .fetch_optional(&mut *tx) .await?; tx.commit().await?; + if resource_o.is_none() && q.get_draft { + // No deployed row + `get_draft`: synthesize the response from the draft + // alone (`no_deployed = true`); see scripts.rs. + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await? + { + return Ok(Json(overlay)); + } + } if resource_o.is_none() { explain_resource_perm_error(&path, &w_id, &db, &authed).await?; } let resource = not_found_if_none(resource_o, "Resource", path)?; - Ok(Json(resource)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Resource, + path, + q.get_draft, + resource, + ) + .await?; + Ok(Json(overlay)) } async fn exists_resource( @@ -1168,6 +1311,13 @@ async fn delete_resource( .await?; tx.commit().await?; + // Resource gone for everyone: wipe ALL users' drafts at this path (and any linked + // variables cascaded into) so teammates' drafts don't orphan. Idempotent on no-draft. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + for var_path in &deleted_linked_variables { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + handle_deployment_metadata( &authed.email, &authed.username, @@ -1437,6 +1587,14 @@ async fn delete_resources_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked variables); see delete_resource. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + for var_path in &linked_var_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + try_join_all(deleted_paths.iter().map(|path| { handle_deployment_metadata( &authed.email, @@ -1664,6 +1822,28 @@ async fn update_resource( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the draft at the OLD path orphans (no SQL FK); clear the deployer's + // own (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). The linked + // variable renames alongside the resource, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 347415f27e..72ed20cb71 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -35,6 +35,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{Error, JsonResult, Result}, scripts::ScriptHash, + user_drafts::{ + decrypt_draft_secret_value, delete_all_drafts_for_path, delete_own_draft_for_path, + fetch_draft_only, fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, + WithDraftOverlay, ENCRYPTED_DRAFT_PREFIX, + }, utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt}, variables::{ build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable, @@ -109,11 +114,15 @@ struct ListVariableQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lq): Query, Query(pagination): Query, @@ -143,6 +152,13 @@ async fn list_variables( "variable.edited_at", "variable.edited_by", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = variable.workspace_id \ + AND draft.path = variable.path AND draft.typ = 'variable' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("account") .on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id)) @@ -199,7 +215,7 @@ async fn list_variables( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "variables", "read"); - let rows = sqlx::query_as::<_, ListableVariable>(&sql) + let mut rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -207,13 +223,102 @@ async fn list_variables( .collect::>(); tx.commit().await?; + + // Append the authed user's draft-only variables; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let variable = v + .get("variable") + .cloned() + .unwrap_or(serde_json::Value::Null); + let is_secret = variable + .get("is_secret") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let description = variable + .get("description") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Secret variables never expose their value in the list response, even from a draft. + let value = if is_secret { + None + } else { + variable + .get("value") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + }; + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableVariable { + workspace_id: w_id.clone(), + path, + value, + is_secret, + description, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + account: None, + is_oauth: None, + is_expired: None, + is_refreshed: None, + refresh_error: None, + is_linked: None, + expires_at: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + edited_at: Some(row.created_at), + edited_by: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } +// `get_draft` inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. #[derive(Deserialize)] struct GetVariableQuery { decrypt_secret: Option, include_encrypted: Option, + #[serde(default)] + get_draft: bool, } async fn get_variable( @@ -222,7 +327,7 @@ async fn get_variable( Extension(db): Extension, Query(q): Query, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("variables:read:{}", path))?; @@ -252,6 +357,17 @@ async fn get_variable( let variable = if let Some(variable) = variable_o { variable + } else if q.get_draft { + // No deployed row + `get_draft`: fall back to the draft (see scripts.rs). + // Drop the user_db tx first since `fetch_draft_only` runs on `db`. + tx.commit().await?; + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await? + { + return Ok(Json(overlay)); + } + explain_variable_perm_error(&path, &w_id, &db).await?; + unreachable!() } else { explain_variable_perm_error(&path, &w_id, &db).await?; unreachable!() @@ -310,7 +426,17 @@ async fn get_variable( variable }; - Ok(Json(r)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Variable, + path, + q.get_draft, + r, + ) + .await?; + Ok(Json(overlay)) } #[derive(Deserialize)] @@ -449,8 +575,15 @@ async fn create_variable( check_path_conflict(&db, &w_id, &variable.path).await?; let value = if variable.is_secret && !already_encrypted.unwrap_or(false) { + // A restored draft sends the `$encrypted:` marker as-is; decrypt it back + // (validating against the workspace key) before the secret backend re-stores it. + let plain = if variable.value.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &variable.value).await? + } else { + variable.value.clone() + }; // Use secret backend for encryption (supports both DB and Vault) - store_secret_value(&db, &w_id, &variable.path, &variable.value).await? + store_secret_value(&db, &w_id, &variable.path, &plain).await? } else { variable.value }; @@ -647,6 +780,13 @@ async fn delete_variable( tx.commit().await?; + // Variable gone for everyone: wipe ALL users' drafts at this path (see resources.rs). + // Resource included because variables cascade-delete the linked resource at the same path. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + if deleted_linked_resource.is_some() { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // If variable was a secret, also delete from Vault backend (if configured) if is_secret { delete_secret_from_backend(&db, &w_id, path).await?; @@ -785,12 +925,12 @@ async fn delete_variables_bulk( ) .execute(&mut *tx) .await?; - sqlx::query!( - "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", + let deleted_resource_paths = sqlx::query_scalar!( + "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &deleted_paths, w_id ) - .execute(&mut *tx) + .fetch_all(&mut *tx) .await?; sqlx::query!( @@ -814,6 +954,14 @@ async fn delete_variables_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked resources); see delete_variable. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + } + for path in &deleted_resource_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // Delete secrets from Vault backend (if configured) for path in &secret_paths { if deleted_paths.contains(path) { @@ -918,9 +1066,15 @@ async fn update_variable( }; let value = if is_secret && !already_encrypted.unwrap_or(false) { + // Decrypt a restored draft's `$encrypted:` marker before re-storing; see create_variable. + let plain = if nvalue.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &nvalue).await? + } else { + nvalue + }; // Use secret backend for encryption (supports both DB and Vault) // Store at target_path (new path if renaming, otherwise current path) - store_secret_value(&db, &w_id, target_path, &nvalue).await? + store_secret_value(&db, &w_id, target_path, &plain).await? } else { nvalue }; @@ -1171,6 +1325,27 @@ async fn update_variable( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the old-path draft orphans (see resources.rs); the linked resource + // renames alongside the variable, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index ae2de880a4..c9c019c6e8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -773,7 +773,6 @@ pub async fn assert_lockfile( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -796,6 +795,7 @@ pub async fn assert_lockfile( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await @@ -871,7 +871,6 @@ pub async fn run_deployed_relative_imports( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -894,6 +893,7 @@ pub async fn run_deployed_relative_imports( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await diff --git a/backend/windmill-trigger-azure/src/handler_oss.rs b/backend/windmill-trigger-azure/src/handler_oss.rs index e56c76a2fd..30257203b6 100644 --- a/backend/windmill-trigger-azure/src/handler_oss.rs +++ b/backend/windmill-trigger-azure/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for AzureTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerAzure; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/azure_triggers"; diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index b6cac94cc2..fccbde4b96 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for EmailTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerEmail; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/email_triggers"; diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index 1cacf7f594..c7e444f5ac 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for GcpTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerGcp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/gcp_triggers"; diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 74fd748f55..7b617e1b8d 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -373,6 +373,7 @@ impl TriggerCrud for HttpTrigger { const TABLE_NAME: &'static str = "http_trigger"; const TRIGGER_TYPE: &'static str = "http"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/http_triggers"; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index 57e786b0ea..0445664ddb 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for KafkaTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerKafka; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/kafka_triggers"; diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 49fb701241..f1a6b6ff9d 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -25,6 +25,7 @@ impl TriggerCrud for MqttTrigger { const TABLE_NAME: &'static str = "mqtt_trigger"; const TRIGGER_TYPE: &'static str = "mqtt"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerMqtt; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/mqtt_triggers"; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index 226bd653fd..b377774ee3 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for NatsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerNats; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/nats_triggers"; diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index dc2f4776fd..10a26e0066 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -46,6 +46,7 @@ impl TriggerCrud for PostgresTrigger { const TABLE_NAME: &'static str = "postgres_trigger"; const TRIGGER_TYPE: &'static str = "postgres"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerPostgres; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/postgres_triggers"; diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index fc72159e24..0f54a548fe 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for SqsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerSqs; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/sqs_triggers"; diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index df8bccaaa6..dd6b9f4363 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -28,6 +28,7 @@ impl TriggerCrud for WebsocketTrigger { const TABLE_NAME: &'static str = "websocket_trigger"; const TRIGGER_TYPE: &'static str = "websocket"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerWebsocket; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/websocket_triggers"; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3591c88931..4f3d381569 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -16,6 +16,10 @@ use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows, + overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, @@ -60,7 +64,10 @@ pub trait TriggerCrud: Send + Sync + 'static { + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Send + Sync - + Unpin; + + Unpin + // `'static` so the deployed trigger can be boxed into + // `WithDraftOverlay`'s erased-serde inner (it's an owned row). + + 'static; type TriggerConfig: Debug + DeserializeOwned @@ -77,6 +84,9 @@ pub trait TriggerCrud: Send + Sync + 'static { /// constant set by each trigger impl — it is never user-controllable. const TABLE_NAME: &'static str; const TRIGGER_TYPE: &'static str; + /// `UserDraftItemKind` for this trigger's per-user `draft` rows. Required (no + /// default) so a trigger that forgets it is a compile error, not a runtime panic. + const DRAFT_KIND: UserDraftItemKind; const SUPPORTS_SERVER_STATE: bool; const SUPPORTS_TEST_CONNECTION: bool; const ROUTE_PREFIX: &'static str; @@ -127,6 +137,11 @@ pub trait TriggerCrud: Send + Sync + 'static { &Self::ROUTE_PREFIX[1..] } + /// Accessor for `DRAFT_KIND` used at the draft-lookup call sites. + fn user_draft_item_kind() -> UserDraftItemKind { + Self::DRAFT_KIND + } + async fn create_trigger( &self, db: &DB, @@ -349,11 +364,14 @@ pub trait TriggerCrud: Send + Sync + 'static { count } + /// `authed_email = Some` adds the per-user `is_draft` flag (scalar EXISTS); + /// `None` (e.g. workspace export) leaves it omitted. async fn list_triggers( &self, tx: &mut PgConnection, workspace_id: &str, query: Option<&StandardTriggerQuery>, + authed_email: Option<&str>, ) -> Result> { let mut fields = vec![ "workspace_id", @@ -381,6 +399,19 @@ pub trait TriggerCrud: Send + Sync + 'static { .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&workspace_id)); + if let Some(email) = authed_email { + // SAFETY: interpolated TABLE_NAME and draft kind are compile-time constants; email is bound. + sqlb.field( + &format!( + "EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = {t}.workspace_id \ + AND draft.path = {t}.path AND draft.typ = '{k}' AND draft.email = ?) as is_draft", + t = Self::TABLE_NAME, + k = Self::user_draft_item_kind().as_str(), + ) + .bind(&email), + ); + } + if let Some(query) = query { let (per_page, offset) = paginate(Pagination { per_page: query.per_page, page: query.page }); @@ -563,15 +594,74 @@ async fn list_triggers( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - let triggers = handler - .list_triggers(&mut *tx, &workspace_id, Some(&query)) + let mut triggers = handler + .list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email)) .await?; tx.commit().await?; + // Append the authed user's draft-only triggers of this kind; see scripts.rs. + // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind + // config can deviate, so drop a row on deserialize failure rather than fail the list. + if query.include_draft_only.unwrap_or(false) + && !authed.is_operator + && query.page.unwrap_or(0) == 0 + && query.path.is_none() + && query.is_flow.is_none() + && query.path_start.is_none() + && query.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + ) + .await?; + + for row in draft_only_rows { + let created_at = row.created_at; + let v: serde_json::Value = match serde_json::from_str(row.value.0.get()) { + Ok(v) => v, + Err(_) => continue, + }; + let serde_json::Value::Object(mut map) = v else { + continue; + }; + // Fill operational fields the editor draft omits so the merged JSON matches + // `Trigger`'s flattened shape (mode derived from `enabled`). + map.insert( + "workspace_id".into(), + serde_json::Value::String(workspace_id.clone()), + ); + map.insert("edited_by".into(), serde_json::Value::String(String::new())); + if let Ok(at) = serde_json::to_value(&created_at) { + map.insert("edited_at".into(), at); + } + map.entry("permissioned_as") + .or_insert(serde_json::Value::String(String::new())); + map.entry("extra_perms").or_insert(serde_json::Value::Null); + if !map.contains_key("mode") { + let enabled = map.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + map.insert( + "mode".into(), + serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.into()), + ); + } + map.insert("draft_only".into(), serde_json::Value::Bool(true)); + // Synthesized rows are the authed user's draft. + map.insert("is_draft".into(), serde_json::Value::Bool(true)); + match serde_json::from_value::(serde_json::Value::Object(map)) { + Ok(t) => triggers.push(t), + Err(_) => continue, + } + } + } + Ok(Json(triggers)) } @@ -579,21 +669,41 @@ async fn get_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; - let trigger = handler + let trigger_res = handler .get_trigger_by_path(&mut *tx, &workspace_id, path) - .await?; - + .await; tx.commit().await?; - Ok(Json(trigger)) + // Map "no deployed trigger" to `None` and let the shared choreography + // handle the draft overlay / draft-only fallback / 404. + let deployed = match trigger_res { + Ok(t) => Some(t), + Err(Error::NotFound(_)) => None, + Err(e) => return Err(e), + }; + + let overlay = overlay_or_draft_only( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + path, + q.get_draft, + deployed, + || Error::NotFound(format!("Trigger not found at path: {}", path)), + ) + .await?; + Ok(Json(overlay)) } async fn update_trigger( @@ -717,6 +827,19 @@ async fn update_trigger( tx.commit().await?; + // On rename the old-path draft orphans (no SQL FK); clear the deployer's own + // (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). See scripts.rs. + if path != new_path { + delete_own_draft_for_path( + &db, + &workspace_id, + T::user_draft_item_kind(), + path, + &authed.email, + ) + .await?; + } + Ok(format!("Trigger '{}' updated", path)) } @@ -724,6 +847,7 @@ async fn delete_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); @@ -781,6 +905,9 @@ async fn delete_trigger( tx.commit().await?; + // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?; + Ok(format!("Trigger '{}' deleted", path)) } diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 8b42c5b1f9..77d90bea20 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -27,6 +27,10 @@ pub struct StandardTriggerQuery { pub is_flow: Option, pub path_start: Option, pub label: Option, + /// When true, append per-user draft rows whose path has no + /// deployed trigger of this kind. Same gate as scripts/flows/apps: + /// non-operators, offset 0, no narrowing filters. + pub include_draft_only: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -42,6 +46,22 @@ pub struct BaseTrigger { pub extra_perms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when this row is a per-user draft with no deployed trigger + /// at the same path. Set by `list_triggers` when the response + /// includes synthesized draft-only rows (gated on + /// `include_draft_only`). Always `None`/omitted on deployed rows + /// fetched from the trigger table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// either layered over a deployed trigger (EXISTS subquery in the + /// list SQL) or a synthesized draft-only row. Drives the `*` suffix + /// on the trigger list pages. `None`/omitted for callers that list + /// without an authed context (e.g. workspace export). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -192,6 +212,7 @@ impl Default for StandardTriggerQuery { path_start: None, is_flow: None, label: None, + include_draft_only: None, } } } @@ -260,6 +281,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 100); assert_eq!(q.limit(), 50); @@ -274,6 +296,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 0); assert_eq!(q.limit(), 100); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 14e2a14fe6..6346fe9cce 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -30,8 +30,6 @@ pub struct Flow { pub schema: Option, pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, @@ -83,7 +81,9 @@ pub struct ListableFlow { pub archived: bool, pub extra_perms: serde_json::Value, pub starred: bool, - pub has_draft: bool, + /// `Some(true)` only on synthesised draft-only rows; `None` on deployed rows. + /// See ListableScript in scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,6 +93,20 @@ pub struct ListableFlow { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this flow (draft-only or layered + /// over the deployed row). See ListableScript in scripts.rs. + #[serde(default)] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -107,7 +121,6 @@ pub struct NewFlow { #[serde(deserialize_with = "validate_flow_value")] pub value: Box, pub schema: Option, - pub draft_only: Option, pub tag: Option, pub dedicated_worker: Option, pub timeout: Option, diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 4d9b96c2af..9144d61f89 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -19,6 +19,8 @@ pub mod schedule; pub mod scripts; #[cfg(not(target_arch = "wasm32"))] pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod user_drafts; /// Duplicated from windmill-common::worker::to_raw_value. /// windmill-types cannot depend on windmill-common (it would be circular). diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 0ab1794d7e..5a7634043c 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -364,13 +364,11 @@ mod tests { assert_eq!(deserialized, S3Permission::READ | S3Permission::WRITE); // Unknown permissions are silently ignored - let deserialized: S3Permission = - serde_json::from_str("\"read,unknown,delete\"").unwrap(); + let deserialized: S3Permission = serde_json::from_str("\"read,unknown,delete\"").unwrap(); assert_eq!(deserialized, S3Permission::READ | S3Permission::DELETE); // All four permissions - let all: S3Permission = - serde_json::from_str("\"read,write,delete,list\"").unwrap(); + let all: S3Permission = serde_json::from_str("\"read,write,delete,list\"").unwrap(); assert_eq!( all, S3Permission::READ | S3Permission::WRITE | S3Permission::DELETE | S3Permission::LIST @@ -409,20 +407,15 @@ mod tests { ); // Region set, endpoint empty → use region - let with_region = S3Resource { - region: "ap-southeast-1".to_string(), - ..resource.clone() - }; + let with_region = S3Resource { region: "ap-southeast-1".to_string(), ..resource.clone() }; assert_eq!( with_region.endpoint_with_region_fallback(Some("ignored".to_string())), "s3.ap-southeast-1.amazonaws.com" ); // Endpoint set → return as-is - let with_endpoint = S3Resource { - endpoint: "custom.s3.endpoint.com".to_string(), - ..resource.clone() - }; + let with_endpoint = + S3Resource { endpoint: "custom.s3.endpoint.com".to_string(), ..resource.clone() }; assert_eq!( with_endpoint.endpoint_with_region_fallback(Some("ignored".to_string())), "custom.s3.endpoint.com" @@ -431,10 +424,8 @@ mod tests { #[test] fn test_lfs_methods_filesystem() { - let rules = vec![S3PermissionRule { - pattern: "**/*.csv".to_string(), - allow: S3Permission::READ, - }]; + let rules = + vec![S3PermissionRule { pattern: "**/*.csv".to_string(), allow: S3Permission::READ }]; let lfs = LargeFileStorage::FilesystemStorage(FilesystemStorage { root_path: "/data/workspace".to_string(), public_resource: Some(true), diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index a777d69790..5e5c7f313e 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -323,7 +323,7 @@ pub fn id_to_codebase_info(id: &str) -> CodebaseInfo { pub const SCRIPT_COLUMNS: &str = concat!( "workspace_id, hash, path, parent_hashes, summary, description, content, ", "created_by, created_at, archived, schema, deleted, is_template, extra_perms, ", - "lock, lock_error_logs, language, kind, tag, draft_only, envs, ", + "lock, lock_error_logs, language, kind, tag, envs, ", "dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ", "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, ", @@ -355,8 +355,6 @@ pub struct Script { pub kind: ScriptKind, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub envs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, @@ -449,8 +447,10 @@ pub struct ListableScript { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_draft: Option, + /// `Some(true)` only on rows synthesised from the `draft` table (never-deployed + /// items the user owns a draft for); `None` on deployed rows. Kept on the public + /// response so consumers checking `draft_only === true` keep working. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, pub has_deploy_errors: bool, @@ -465,6 +465,22 @@ pub struct ListableScript { pub kind: ScriptKind, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `true` when this entry is the authed user's draft — draft-only, or a deployed + /// row the user has saved a draft on top of. Distinguishes user state from team state. + #[serde(skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path, so the home list shows a meaningful name over the + /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use + /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an + /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners (`{ username }`, `None` for the legacy NULL-email row), + /// driving the home-page avatar circles. `None` when no drafts; never an empty array. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -516,7 +532,6 @@ pub struct NewScript { pub language: ScriptLang, pub kind: Option, pub tag: Option, - pub draft_only: Option, pub envs: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, @@ -574,7 +589,6 @@ impl Hash for NewScript { self.language.hash(state); self.kind.hash(state); self.tag.hash(state); - self.draft_only.hash(state); self.envs.hash(state); self.concurrency_settings.hash(state); self.debouncing_settings.hash(state); diff --git a/backend/windmill-types/src/user_drafts.rs b/backend/windmill-types/src/user_drafts.rs new file mode 100644 index 0000000000..30740fc561 --- /dev/null +++ b/backend/windmill-types/src/user_drafts.rs @@ -0,0 +1,18 @@ +//! Shared types for the per-user draft surface. +//! +//! Mirrors the `OtherDraftUser` type in `windmill-common::user_drafts` — +//! kept here so `windmill-types` row structs (ListableScript / ListableFlow / +//! ListableApp) can expose a typed `draft_users` field without taking a +//! dependency on `windmill-common`. The two structs serialize identically, +//! so the frontend doesn't notice. + +use serde::{Deserialize, Serialize}; + +/// One workspace user (or the legacy NULL-email row) with a per-user draft +/// at a given path. Used by the home-page list endpoints to feed the +/// avatar-circles inside the Draft badge. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DraftUserRef { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 1071331822..d7f61151e0 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6952,6 +6952,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' azure_resource_path: type: string azure_mode: @@ -7037,6 +7057,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' local_part: type: string workspaced_local_part: @@ -7102,6 +7142,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for @@ -7203,6 +7263,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' route_path: type: string description: The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). @@ -7337,6 +7417,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' kafka_resource_path: type: string description: Path to the Kafka resource containing connection configuration @@ -7440,6 +7540,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' mqtt_resource_path: type: string description: Path to the MQTT resource containing broker connection configuration @@ -7534,6 +7654,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' nats_resource_path: type: string description: Path to the NATS resource containing connection configuration @@ -7615,6 +7755,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' postgres_resource_path: type: string description: Path to the PostgreSQL resource containing connection configuration @@ -7797,6 +7957,22 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + schedule at the same path. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' inherited_labels: type: array items: @@ -7828,6 +8004,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -7908,6 +8104,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' url: type: string description: The WebSocket URL to connect to (can be a static URL or computed diff --git a/frontend/src/lib/coalescingRunner.svelte.ts b/frontend/src/lib/coalescingRunner.svelte.ts new file mode 100644 index 0000000000..0f687d4829 --- /dev/null +++ b/frontend/src/lib/coalescingRunner.svelte.ts @@ -0,0 +1,118 @@ +/** + * Per-key coalescing async runner: at most one task running and one pending per + * key. `submit` runs `fn` now if idle, else REPLACES the pending task (the + * displaced one never runs); the pending task starts when the running one + * settles. Collapses bursts of "save latest" to the in-flight call plus the + * most recent. Keys are independent. + */ +import { SvelteSet } from 'svelte/reactivity' + +export type CoalescingTask = () => T | Promise + +/** Rejection reason for a `submitAndWait` (or `cancel`-dropped) task discarded + * before it ran. Only awaiters see it, not fire-and-forget `submit` callers. */ +export class CoalescingDisplacedError extends Error { + constructor() { + super('coalescingRunner: pending task displaced before it could run') + this.name = 'CoalescingDisplacedError' + } +} + +export type CoalescingKeyedRunner = { + /** Fire-and-forget schedule per the policy above. */ + submit(key: string, fn: CoalescingTask): void + /** Like `submit`, but the promise resolves/rejects with `fn`'s outcome, or + * rejects with `CoalescingDisplacedError` if dropped before running. */ + submitAndWait(key: string, fn: CoalescingTask): Promise + /** Drop the pending task for `key` (returns whether there was one). Can't + * abort an in-flight task. A dropped `submitAndWait` rejects with + * `CoalescingDisplacedError`. */ + cancel(key: string): boolean + /** Reactively whether `key`'s chain is running (SvelteSet-backed). */ + isRunning(key: string): boolean +} + +type PendingTask = { + fn: CoalescingTask + resolve?: (value: unknown) => void + reject?: (reason: unknown) => void +} + +type Entry = { pending: PendingTask | undefined } + +/** + * @example + * runner.submit('k', f) // runs immediately + * runner.submit('k', g) // f running: g pending + * runner.submit('k', h) // g discarded, h pending; h runs once f settles + */ +export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { + const state = new Map() + // Reactive mirror of keys with a running chain, kept in lock-step with + // `state` (SvelteSet for per-key `isRunning` subscriptions). + const runningKeys = new SvelteSet() + + async function chain(key: string, first: PendingTask): Promise { + let current: PendingTask | undefined = first + while (current) { + try { + const result = await current.fn() + current.resolve?.(result) + } catch (e) { + // Don't kill the chain on failure — later submissions must + // still run. Awaiters get the error via their promise; + // fire-and-forget callers get a console.error. + if (current.reject) current.reject(e) + else console.error('coalescingRunner: task failed', e) + } + const entry = state.get(key)! + current = entry.pending + entry.pending = undefined + } + state.delete(key) + runningKeys.delete(key) + } + + /** Set `task` pending for `key`, displacing (and rejecting) any prior + * pending. If the key is idle, start the chain. */ + function setOrDisplace(key: string, task: PendingTask): void { + const entry = state.get(key) + if (entry) { + entry.pending?.reject?.(new CoalescingDisplacedError()) + entry.pending = task + return + } + state.set(key, { pending: undefined }) + runningKeys.add(key) + void chain(key, task) + } + + function submit(key: string, fn: CoalescingTask): void { + setOrDisplace(key, { fn }) + } + + function submitAndWait(key: string, fn: CoalescingTask): Promise { + return new Promise((resolve, reject) => { + setOrDisplace(key, { + fn: fn as CoalescingTask, + resolve: resolve as (v: unknown) => void, + reject + }) + }) + } + + function cancel(key: string): boolean { + const entry = state.get(key) + if (!entry?.pending) return false + const dropped = entry.pending + entry.pending = undefined + dropped.reject?.(new CoalescingDisplacedError()) + return true + } + + function isRunning(key: string): boolean { + return runningKeys.has(key) + } + + return { submit, submitAndWait, cancel, isRunning } +} diff --git a/frontend/src/lib/coalescingRunner.test.ts b/frontend/src/lib/coalescingRunner.test.ts new file mode 100644 index 0000000000..49cca4688e --- /dev/null +++ b/frontend/src/lib/coalescingRunner.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest' +import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte' + +/** A promise plus its resolve/reject, so a test can decide exactly when a + * task settles. */ +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createCoalescingKeyedRunner', () => { + it('runs a submitted task immediately when the key is idle', () => { + const runner = createCoalescingKeyedRunner() + const fn = vi.fn(() => Promise.resolve()) + runner.submit('k', fn) + expect(fn).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(true) + }) + + it('clears isRunning once the in-flight task settles', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(runner.isRunning('k')).toBe(false) + }) + + it('coalesces a burst down to in-flight + latest-pending', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const f = vi.fn(() => d.promise) + const g = vi.fn(() => Promise.resolve()) + const h = vi.fn(() => Promise.resolve()) + + runner.submit('k', f) // runs now + runner.submit('k', g) // pending + runner.submit('k', h) // displaces g; h is the only pending + + expect(f).toHaveBeenCalledTimes(1) + expect(g).not.toHaveBeenCalled() + expect(h).not.toHaveBeenCalled() + + d.resolve() + await d.promise + await Promise.resolve() + await Promise.resolve() + + expect(g).not.toHaveBeenCalled() // displaced — never ran + expect(h).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(false) + }) + + it('keeps different keys independent', () => { + const runner = createCoalescingKeyedRunner() + const a = vi.fn(() => new Promise(() => {})) + const b = vi.fn(() => new Promise(() => {})) + runner.submit('a', a) + runner.submit('b', b) // different key → runs immediately too + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + expect(runner.isRunning('a')).toBe(true) + expect(runner.isRunning('b')).toBe(true) + }) + + it('submitAndWait resolves with the task result', async () => { + const runner = createCoalescingKeyedRunner() + await expect(runner.submitAndWait('k', () => Promise.resolve(42))).resolves.toBe(42) + }) + + it('submitAndWait rejects with the task error', async () => { + const runner = createCoalescingKeyedRunner() + await expect( + runner.submitAndWait('k', () => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom') + }) + + it('rejects a displaced submitAndWait with CoalescingDisplacedError', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) // hold the key busy + const dropped = runner.submitAndWait('k', () => Promise.resolve('a')) // pending + runner.submit('k', () => Promise.resolve('b')) // displaces it + await expect(dropped).rejects.toBeInstanceOf(CoalescingDisplacedError) + d.resolve() + }) + + it('cancel drops the pending task and rejects its awaiter', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const pending = vi.fn(() => Promise.resolve()) + runner.submit('k', () => d.promise) // in flight + const awaited = runner.submitAndWait('k', pending) // pending + + expect(runner.cancel('k')).toBe(true) + await expect(awaited).rejects.toBeInstanceOf(CoalescingDisplacedError) + + d.resolve() + await d.promise + await Promise.resolve() + expect(pending).not.toHaveBeenCalled() // cancelled before it could run + }) + + it('cancel returns false when there is no pending task', () => { + const runner = createCoalescingKeyedRunner() + expect(runner.cancel('k')).toBe(false) + runner.submit('k', () => new Promise(() => {})) // running, nothing pending + expect(runner.cancel('k')).toBe(false) + }) + + it('does not abort the in-flight task on cancel', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const inflight = vi.fn(() => d.promise) + runner.submit('k', inflight) + runner.cancel('k') // only affects pending; nothing pending here + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(inflight).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte new file mode 100644 index 0000000000..168f68ebd7 --- /dev/null +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -0,0 +1,339 @@ + + +
+ {#if flashActive} + + {#key flashKey} + + {/key} + {/if} + + {#snippet trigger()} +
+ {#if syncState === 'saving' || syncState === 'pending'} + + {:else if syncState === 'failed'} + + {:else if !autosaveEnabled} + + + {:else} + + {/if} +
+ {/snippet} + + {#snippet content()} +
+ {#if syncState === 'failed'} +
+

Save failed

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

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

+ {:else} +

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

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

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

+ +
+ {/if} + {#if showResetAction} + + {/if} +
+ {/snippet} +
+ {#if label} + {label} + {/if} +
+ + diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 386007317c..264efb73a0 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -13,6 +13,7 @@ import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' import { type DraftItem } from '$lib/workspaceDrafts.svelte' + import type { Kind as LayoutKind } from '$lib/utils_deployable' interface Props { currentWorkspaceId: string @@ -52,7 +53,11 @@ }: Props = $props() type Row = { - kind: DraftItem['kind'] + /** The deploy layout's `Kind` naming (`http_trigger`, `schedule`, + * ...) — the layout reads `item.kind` for the row icon. Our + * `UserDraftItemKind` naming lives in `draftKind`. */ + kind: LayoutKind + draftKind: DraftItem['kind'] path: string summary?: string draft_only: boolean @@ -63,11 +68,43 @@ return `${kind}:${path}` } + // UserDraftItemKind → the deploy layout's Kind union (drives row icons). + // Trigger kinds swap the prefix to a suffix; kinds the layout doesn't + // know (webhook, native triggers) borrow the generic 'trigger' icon. + function toLayoutKind(kind: DraftItem['kind']): LayoutKind { + if (kind === 'trigger_schedule') return 'schedule' + if (kind === 'trigger_default_email') return 'email_trigger' + if (kind.startsWith('trigger_')) { + const candidate = `${kind.slice('trigger_'.length)}_trigger` + const known = [ + 'http_trigger', + 'websocket_trigger', + 'kafka_trigger', + 'nats_trigger', + 'postgres_trigger', + 'mqtt_trigger', + 'sqs_trigger', + 'gcp_trigger', + 'azure_trigger', + 'email_trigger' + ] + return (known.includes(candidate) ? candidate : 'trigger') as LayoutKind + } + return kind as LayoutKind + } + // The list (and the Draft Count) come from the shared Workspace Drafts module, // owned by the page and passed in via `draftItems`; deploy/discard invalidate // that resource, so the list refetches and deployed items drop off without a // manual reload here. - const items: Row[] = $derived(draftItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path) }))) + const items: Row[] = $derived( + draftItems.map((d) => ({ + ...d, + key: getItemKey(d.kind, d.path), + kind: toLayoutKind(d.kind), + draftKind: d.kind + })) + ) // The Draft Items list only carries the *deployed* summary, so the draft's // (new) display name isn't known yet. Fetch each item's draft blob once and @@ -84,13 +121,22 @@ if (summaryCache[item.key]) return summaryCache[item.key] = { loading: true } try { - const r = (await (item.kind === 'script' - ? ScriptService.getScriptByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : item.kind === 'flow' - ? FlowService.getFlowByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : AppService.getAppByPathWithDraft({ + const r = (await (item.draftKind === 'script' + ? ScriptService.getScriptByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true + }) + : item.draftKind === 'flow' + ? FlowService.getFlowByPath({ workspace: currentWorkspaceId, - path: item.path + path: item.path, + getDraft: true + }) + : AppService.getAppByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true }))) as any summaryCache[item.key] = { deployed: r.summary, @@ -107,7 +153,12 @@ const current = items untrack(() => { for (const item of current) { - if (!item.draft_only && !item.raw_app && !summaryCache[item.key]) { + if ( + !item.draft_only && + !item.raw_app && + ['script', 'flow', 'app'].includes(item.draftKind) && + !summaryCache[item.key] + ) { void fetchDraftSummary(item) } } @@ -192,10 +243,10 @@ async function showDiff(item: Row) { if (!diffDrawer) return const reqId = ++diffRequestId - isFlow = item.kind === 'flow' + isFlow = item.draftKind === 'flow' diffDrawer.openDrawer() const { deployed, draft } = await getDraftDiffValues( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only @@ -220,7 +271,7 @@ for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } const res = await deployDraft( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only, @@ -249,7 +300,7 @@ const item = discardTarget discardTarget = undefined if (!item) return - const res = await discardDraft(item.kind, item.path, currentWorkspaceId, item.draft_only) + const res = await discardDraft(item.draftKind, item.path, currentWorkspaceId, item.draft_only) if (res.success) { sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`) // discardDraft invalidated the Draft list; refresh the fork comparison. @@ -260,13 +311,43 @@ } // Editor URL for a draft item, scoped to the current workspace. Raw apps live - // under a different editor route, so map their kind accordingly. + // under a different editor route, so map their kind accordingly. Kinds whose + // editor is a drawer on a list page (variables, resources, schedules, + // triggers) link to that page with the item path as the hash anchor. + const LIST_PAGE_FOR_KIND: Partial> = { + variable: '/variables', + resource: '/resources', + trigger_schedule: '/schedules', + trigger_http: '/routes', + trigger_websocket: '/websocket_triggers', + trigger_postgres: '/postgres_triggers', + trigger_kafka: '/kafka_triggers', + trigger_nats: '/nats_triggers', + trigger_mqtt: '/mqtt_triggers', + trigger_sqs: '/sqs_triggers', + trigger_gcp: '/gcp_triggers', + trigger_azure: '/azure_triggers', + trigger_email: '/email_triggers' + } function draftEditUrl(d: Row): string | undefined { + const listPage = LIST_PAGE_FOR_KIND[d.draftKind] + if (listPage) { + return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}` + } return editUrlFor( - { kind: d.raw_app ? 'raw_app' : d.kind, path: d.path } as unknown as WorkspaceItemDiff, + { kind: d.raw_app ? 'raw_app' : d.draftKind, path: d.path } as unknown as WorkspaceItemDiff, currentWorkspaceId ) } + + // Human label for the kind badge on each row — without it a variable + // draft and a script draft at the same path are indistinguishable. + function kindLabel(kind: Row['draftKind']): string { + if (kind === 'raw_app') return 'app' + if (kind === 'trigger_schedule') return 'schedule' + if (kind.startsWith('trigger_')) return `${kind.slice('trigger_'.length)} trigger` + return kind + }
@@ -335,6 +416,7 @@ {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} + {kindLabel(draftItem.draftKind)} {#if draftItem.draft_only} New {/if} diff --git a/frontend/src/lib/components/ContentSearchInner.svelte b/frontend/src/lib/components/ContentSearchInner.svelte index 8b20855193..d3030254ad 100644 --- a/frontend/src/lib/components/ContentSearchInner.svelte +++ b/frontend/src/lib/components/ContentSearchInner.svelte @@ -363,11 +363,7 @@ > Open - {/snippet} diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index 0f89cfc3dd..58e245d74e 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -87,7 +87,7 @@ | { mode: 'normal' deployed: Value - draft: Value | undefined + draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' button?: { text: string; onClick: () => void } diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index a5bcf73df5..99c5af259b 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -1,29 +1,282 @@ -{#if has_draft} - {#if draft_only} - - {#snippet text()} - Never deployed and is only a draft - {/snippet} - Draft only - - {:else} - - {#snippet text()} - Is deployed and has a draft - {/snippet} - +Draft - - {/if} +{#if showBadge} + + {#snippet trigger()} + + {#if orderedUsers.length > 0} + + + {#each visibleUsers as u, i (i)} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} + + {/if} + {draft_only ? 'Draft only' : 'Draft'} + + {/snippet} + {#snippet content()} +
+

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

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

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

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

Summary

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

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

+ {#if conflictHandle.conflict} +

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

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

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

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

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

+

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

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

Data configuration

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

+ + Start with AI + (optional) +

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

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

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

Summary

- -
- - -
-

Framework

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

Data configuration

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

- - Start with AI - (optional) -

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

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

-
- {/if} -
- - -
- - {#if isAiEnabled} - - {/if} -
-
-
-{/if} -{#key reloadCounter} - { - goto(`/apps_raw/edit/${event.detail}`) - }} - bind:files - bind:runnables - bind:data - {policy} - path={''} - liveEditorDraftStoragePath="" - bind:summary - newApp - /> -{/key} - - { - preWhitelistedTables = [...preWhitelistedTables, ref] - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts new file mode 100644 index 0000000000..ea96e67846 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps_raw/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 84ce23e7a5..3fc657c3ae 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,15 +1,9 @@ - - + loadApp()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + } + }) + }} /> + + {#if files} {#key redraw}
{ - UserDraft.remove('raw_app', path) + draftSync.remove() goto(`/apps_raw/edit/${event.detail}`) newPath = event.detail }} @@ -392,13 +433,21 @@ bind:runnables bind:data bind:summary + bind:pendingDraftPath {newPath} path={page.params.path ?? ''} liveEditorDraftStoragePath={path} {policy} bind:savedApp {diffDrawer} - newApp={false} + newApp={isNewApp} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} />
{/key} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index eefa19fcd3..9fdf4c5c7d 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -58,7 +58,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps_raw/edit/{page.params.path}?nodraft=true">EditEdit {/if} diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index a8d0b566b8..2d03b85398 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/edit/${e.path}?selected=${e.id}`) - }} - onDeploy={(e) => { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onDetails={(e) => { - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - {initialPath} - {pathStoreInit} - liveEditorDraftStoragePath="" - bind:this={flowBuilder} - newFlow - {initialArgs} - {flowStore} - {flowStateStore} - {selectedId} - {loading} - {draftTriggersFromUrl} - {selectedTriggerIndexFromUrl} - noInitial -> - - + diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.ts b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts new file mode 100644 index 0000000000..153c2255df --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('flows/edit') diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index b979a09758..1e1fcdccbc 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -1,35 +1,30 @@ - - + loadFlow()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + } + }) + }} /> {#if notFound}
@@ -404,25 +441,36 @@ {:else if renderEditor} { - UserDraft.remove('flow', flowDraftPath) + // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} - onSaveDraftOnlyAtNewPath={(e) => { - goto(`/flows/edit/${e.path}?selected=${e.selectedId}`) - }} onHistoryRestore={() => { loadFlow() }} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} - initialPath={page.params.path ?? ''} + bind:initialPath={flowInitialPath} liveEditorDraftStoragePath={flowDraftPath} - newFlow={false} + newFlow={isNewFlow} {selectedId} {initialArgs} {loading} @@ -434,10 +482,5 @@ {selectedTriggerIndexFromUrl} {version} {loadedFromHistoryFromUrl} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 088563b640..8741a8b93b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -329,7 +329,7 @@ onClick: async () => { const app = createAppFromFlow(flow.path, flow.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, unifiedSize: 'md', variant: 'subtle', @@ -341,7 +341,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: `${base}/flows/edit/${path}?nodraft=true`, + href: `${base}/flows/edit/${path}`, variant: 'accent', unifiedSize: 'md', disabled: !can_write || !showEditButtons, diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index bb65a27065..6e59e7be5e 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - -{#if scriptHandle.draft} - { - // "Deploy & Stay here" / lib: stay on the editor (just confirm). - if (e.stay) { - sendUserToast('Deployed') - return - } - goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) - }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - searchParams={page.url.searchParams} - bind:script={scriptHandle.draft} - {showMeta} - > - - -{:else} - -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts new file mode 100644 index 0000000000..05237ef0db --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('scripts/edit') diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index b76c03a804..f04ef93188 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -1,95 +1,40 @@ - - + loadScript()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + } + }) + }} /> - - -{#if scriptHandle.draft && renderEditor} +{#if draftSync.draft && renderEditor} (othersModalOpen = true)} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + }} onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) { sendUserToast('Deployed') return } - UserDraft.remove('script', draftPath) + // stopSync-bracketed immediate delete; see restoreDeployed for the race. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index df37a946f9..24bf997c50 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -422,7 +422,7 @@ onClick: async () => { const app = createAppFromScript(script.path, script.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, disabled: !showEditButtons, unifiedSize: 'md', diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b70730bd8c..586d2077d2 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,4 +1,5 @@