mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
* feat: operators get AI sessions `prefersSessionHandoff` stops taking the role, so every AI entry point an operator can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off, the raw-app template picker — opens a session for them like anyone else. The sessions page drops its refusal, and the layout stops keeping them on the docked chat. In the sidebar they now get the Workspace/AI Sessions switch and session mode, and lose the Ask AI entry that stood in for them, since the pane behind it is no longer the one they are sent to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: operators get run-oriented examples in the home composer Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: note builder_flows in the operator composer examples comment Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: gate session tools and editors on operator rights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep operator AI sessions behind a browser opt-in Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep flows refused in the prompt for operators without builder rights Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: decide every frontend edit check through one editRights module Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: let admins who are also operators open session editors, as drafts.rs does Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: mirror drafts.rs exactly in roleCanDraft and correct the edit-rights docs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
export type WorkspaceItemKind = 'flow' | 'script' | 'app'
|
|
|
|
export type WorkspaceItem = {
|
|
path: string
|
|
summary: string
|
|
kind: WorkspaceItemKind
|
|
raw_app?: boolean
|
|
/** User-typed friendly path for a draft-only item parked at a
|
|
* `…/draft_<uuid>` storage path (the backend lists' `draft_path`).
|
|
* Display-only: `path` stays the storage key and is what URLs and
|
|
* dedupe must use — routing to the friendly path 404s. */
|
|
draftPath?: string
|
|
/** Nothing is deployed at this path: the row exists only because the
|
|
* listers were asked for `include_draft_only`. Decides which side of an
|
|
* item a picker opens, so it must be invalidated on deploy — a stale
|
|
* `true` sends the reader to the editor of something they just shipped. */
|
|
draftOnly?: boolean
|
|
/** The authed user has a draft for this item. True for a draft-only item too,
|
|
* so read it with `draftOnly` to tell "nothing deployed" from "deployed, with
|
|
* edits pending". */
|
|
hasDraft?: boolean
|
|
/** The item's own sharing, which `canEditItem` reads with the folder rules. */
|
|
extraPerms?: Record<string, boolean>
|
|
}
|
|
|
|
/** Path to display (and group by) in pickers: the friendly draft path when
|
|
* the item has one, else the real path. */
|
|
export function workspaceItemDisplayPath(item: Pick<WorkspaceItem, 'path' | 'draftPath'>): string {
|
|
return item.draftPath ?? item.path
|
|
}
|
|
|
|
/** Display label for kinds — used in the picker rows and search section headers. */
|
|
export const KIND_LABEL: Record<WorkspaceItemKind, string> = {
|
|
flow: 'Flows',
|
|
script: 'Scripts',
|
|
app: 'Apps'
|
|
}
|
|
|
|
/** Lowercase variant — matches the editor breadcrumb style. */
|
|
export const KIND_LABEL_LOWER: Record<WorkspaceItemKind, string> = {
|
|
flow: 'flows',
|
|
script: 'scripts',
|
|
app: 'apps'
|
|
}
|
|
|
|
/** Composite keys used for keyboard nav and `initialHighlight`. The picker and
|
|
* its callers must agree on these — keep them all here. `'all'` is a virtual
|
|
* kind for the cross-kind root view; only valid for `kindKey` / `dirKey`
|
|
* (leaves always belong to a real kind). */
|
|
export const kindKey = (k: WorkspaceItemKind | 'all') => `kind:${k}`
|
|
export const dirKey = (k: WorkspaceItemKind | 'all', fullPath: string) => `dir:${k}:${fullPath}`
|
|
export const leafKeyFor = (k: WorkspaceItemKind, path: string) => `leaf:${k}:${path}`
|
|
|
|
export function editPathFor(item: WorkspaceItem): string {
|
|
if (item.kind === 'flow') return `/flows/edit/${item.path}`
|
|
if (item.kind === 'script') return `/scripts/edit/${item.path}`
|
|
return item.raw_app ? `/apps_raw/edit/${item.path}` : `/apps/edit/${item.path}`
|
|
}
|
|
|
|
/** The deployed-version page for an item, the `/get/` counterpart of {@link editPathFor}. */
|
|
export function viewPathFor(item: WorkspaceItem): string {
|
|
if (item.kind === 'flow') return `/flows/get/${item.path}`
|
|
if (item.kind === 'script') return `/scripts/get/${item.path}`
|
|
return item.raw_app ? `/apps_raw/get/${item.path}` : `/apps/get/${item.path}`
|
|
}
|
|
|
|
type WorkspaceCache = {
|
|
flow?: WorkspaceItem[]
|
|
script?: WorkspaceItem[]
|
|
app?: WorkspaceItem[]
|
|
}
|
|
|
|
/** Module-level session cache. Persists across picker mounts within a single
|
|
* page session so cached kinds render on the first frame. Pickers revalidate
|
|
* once per mount via `loadKind(..., { revalidate: true })`, so stale entries
|
|
* self-heal on the next open; call `invalidate()` after creating/deleting an
|
|
* item when even a brief flash of the stale list must be avoided. */
|
|
const cache = new Map<string, WorkspaceCache>()
|
|
const inflight = new Map<string, Promise<WorkspaceItem[]>>()
|
|
/** Bumped by `invalidate()`. Each in-flight `loadKind` captures the version
|
|
* at start and only writes back to the cache if it still matches — so a
|
|
* deploy mid-fetch can't have its stale predecessor repopulate the cache. */
|
|
const cacheVersion = new Map<string, number>()
|
|
|
|
const cacheKey = (workspace: string, kind: WorkspaceItemKind) => `${workspace}:${kind}`
|
|
|
|
const KINDS: WorkspaceItemKind[] = ['flow', 'script', 'app']
|
|
|
|
function bumpVersion(workspace: string, kind: WorkspaceItemKind) {
|
|
const k = cacheKey(workspace, kind)
|
|
cacheVersion.set(k, (cacheVersion.get(k) ?? 0) + 1)
|
|
}
|
|
|
|
export function getCachedItems(
|
|
workspace: string,
|
|
kind: WorkspaceItemKind
|
|
): WorkspaceItem[] | undefined {
|
|
return cache.get(workspace)?.[kind]
|
|
}
|
|
|
|
/** Drop a workspace+kind (or a whole workspace) from the cache so the next
|
|
* picker open re-fetches. Use after creating/deleting items. Also bumps the
|
|
* version so any in-flight `loadKind` started before the invalidate won't
|
|
* write its (now-stale) result back to the cache. */
|
|
export function invalidate(workspace: string, kind?: WorkspaceItemKind) {
|
|
if (!kind) {
|
|
cache.delete(workspace)
|
|
for (const k of KINDS) bumpVersion(workspace, k)
|
|
return
|
|
}
|
|
const bucket = cache.get(workspace)
|
|
if (bucket) delete bucket[kind]
|
|
bumpVersion(workspace, kind)
|
|
}
|
|
|
|
export async function loadKind(
|
|
workspace: string,
|
|
kind: WorkspaceItemKind,
|
|
opts?: { revalidate?: boolean }
|
|
): Promise<WorkspaceItem[]> {
|
|
const existing = cache.get(workspace)?.[kind]
|
|
if (existing && !opts?.revalidate) return existing
|
|
const key = cacheKey(workspace, kind)
|
|
// An in-flight fetch is already hitting the network, so it satisfies a
|
|
// revalidate request too.
|
|
const flying = inflight.get(key)
|
|
if (flying) return flying
|
|
|
|
const startVersion = cacheVersion.get(key) ?? 0
|
|
const promise = (async () => {
|
|
const { ScriptService, FlowService, AppService } = await import('$lib/gen')
|
|
let items: WorkspaceItem[]
|
|
if (kind === 'flow') {
|
|
const flows = await FlowService.listFlows({
|
|
workspace,
|
|
includeDraftOnly: true,
|
|
withoutDescription: true
|
|
})
|
|
items = flows.map((f) => ({
|
|
path: f.path,
|
|
summary: f.summary ?? '',
|
|
kind: 'flow' as const,
|
|
draftPath: f.draft_path,
|
|
draftOnly: f.draft_only ?? false,
|
|
hasDraft: f.is_draft ?? false,
|
|
extraPerms: f.extra_perms
|
|
}))
|
|
} else if (kind === 'script') {
|
|
const scripts = await ScriptService.listScripts({
|
|
workspace,
|
|
includeDraftOnly: true,
|
|
withoutDescription: true
|
|
})
|
|
items = scripts.map((s) => ({
|
|
path: s.path,
|
|
summary: s.summary ?? '',
|
|
kind: 'script' as const,
|
|
draftPath: s.draft_path,
|
|
draftOnly: s.draft_only ?? false,
|
|
hasDraft: s.is_draft ?? false,
|
|
extraPerms: s.extra_perms
|
|
}))
|
|
} else {
|
|
const apps = await AppService.listApps({
|
|
workspace,
|
|
includeDraftOnly: true
|
|
})
|
|
items = apps.map((a) => ({
|
|
path: a.path,
|
|
summary: a.summary ?? '',
|
|
kind: 'app' as const,
|
|
raw_app: a.raw_app ?? false,
|
|
draftPath: a.draft_path,
|
|
draftOnly: a.draft_only ?? false,
|
|
hasDraft: a.is_draft ?? false,
|
|
extraPerms: a.extra_perms
|
|
}))
|
|
}
|
|
// Only commit if the cache version hasn't changed since we started —
|
|
// otherwise we'd overwrite a deliberate `invalidate()` with stale data.
|
|
if ((cacheVersion.get(key) ?? 0) === startVersion) {
|
|
const bucket = cache.get(workspace) ?? {}
|
|
bucket[kind] = items
|
|
cache.set(workspace, bucket)
|
|
}
|
|
return items
|
|
})()
|
|
inflight.set(key, promise)
|
|
try {
|
|
return await promise
|
|
} finally {
|
|
inflight.delete(key)
|
|
}
|
|
}
|