mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
* fix: do not read an unloaded workspace list as a non-membership `roleForWorkspace` settled `not_a_member` from `userWorkspaces` alone. That store and `superadmin` both start undefined and load asynchronously, so an unloaded list read as an empty one: a chat operating on any workspace other than the one being browsed advertised no pages and reported an access denial. The root layout gives up after its retries, so a load that fails leaves the denial permanent, with `whoami` never attempted. Settle a non-membership only once both stores have resolved; treat unresolved as unknown and fall through to the `whoami` lookup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: build the global chat's prompt identity from the operating workspace The prompt's path conventions and folder guidance came from the ambient `userStore`, which describes the workspace being browsed rather than the one the chat operates on. Three of those fields are per-workspace and wrong whenever the two differ: the username (that workspace's `usr` row), the writable/readable folder sets (its ACLs), and `is_admin`, which decides whether the folder list reads as exhaustive. The backend still enforces the ACLs, so the cost is prompt quality — paths the model cannot write to, and a 403 to recover from. Resolve the identity for the operating workspace and feed that to the prompt, refreshed alongside skills and MCP servers and settled in `beforeSend` so the cached system-prompt prefix stays stable for the turn. An unresolved role now leaves the folder sets undefined rather than empty, so the guidance is dropped instead of claiming there is nothing to write to, and `create_folder` credits the workspace it wrote to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the AI provider resource types lazily `Object.keys(AI_PROVIDERS)` at module scope made `AI_PROVIDERS` a load-time requirement for every importer of this module, the global chat included. `AIChatManager.test.ts` mocks `../lib` without it and has been unable to load since the catalog was introduced; no CI workflow runs vitest, so nothing reported it. The constant is read in two places, both inside functions, so deferring it removes the load-time dependency without changing behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { BROWSER } from 'esm-env'
|
|
import { derived, get, type Readable, writable } from 'svelte/store'
|
|
|
|
import type { IntrospectionQuery } from 'graphql'
|
|
import {
|
|
CancelablePromise,
|
|
type OperatorSettings,
|
|
type TokenResponse,
|
|
type UserWorkspaceList,
|
|
type Workspace,
|
|
type WorkspaceDefaultScripts,
|
|
WorkspaceService
|
|
} from './gen'
|
|
import { getLocalSetting, type StateStore } from './utils'
|
|
import { createState } from './svelte5Utils.svelte'
|
|
import { DEFAULT_HUB_BASE_URL } from './hub'
|
|
import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte'
|
|
|
|
export interface UserExt {
|
|
// Workspace this membership was fetched for. `$workspaceStore` flips
|
|
// synchronously on a switch while the new `whoami` is still in flight, so a
|
|
// consumer whose behavior depends on the role must compare this against the
|
|
// active workspace rather than read a role that still describes the previous one.
|
|
workspace_id: string
|
|
email: string
|
|
name?: string
|
|
username: string
|
|
is_admin: boolean
|
|
is_super_admin: boolean
|
|
operator: boolean
|
|
created_at: string
|
|
groups: string[]
|
|
pgroups: string[]
|
|
folders: string[]
|
|
folders_read: string[]
|
|
folders_owners: string[]
|
|
is_service_account?: boolean
|
|
impersonating_email?: string
|
|
// true when the user is a superadmin viewing a workspace they are not a member of
|
|
non_member?: boolean
|
|
}
|
|
|
|
export interface UserWorkspace {
|
|
id: string
|
|
name: string
|
|
username: string
|
|
color?: string
|
|
operator_settings?: OperatorSettings
|
|
parent_workspace_id?: string | null
|
|
is_dev_workspace?: boolean
|
|
dev_workspace_label?: string | null
|
|
created_by?: string | null
|
|
disabled: boolean
|
|
}
|
|
|
|
const persistedWorkspace = BROWSER && getWorkspaceFromStorage()
|
|
|
|
export function getWorkspaceFromStorage(): string | undefined {
|
|
try {
|
|
return sessionStorage.getItem('workspace') ?? localStorage.getItem('workspace') ?? undefined
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
return undefined
|
|
}
|
|
export function clearWorkspaceFromStorage() {
|
|
localStorage.removeItem('workspace')
|
|
sessionStorage.removeItem('workspace')
|
|
}
|
|
|
|
export const tutorialsToDo = writable<number[]>([])
|
|
export const skippedAll = writable<boolean>(false)
|
|
export const globalEmailInvite = writable<string>('')
|
|
export const awarenessStore = writable<Record<string, string>>(undefined)
|
|
export const enterpriseLicense = writable<string | undefined>(undefined)
|
|
export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLicense]) => {
|
|
if (enterpriseLicense?.endsWith('__whitelabel')) {
|
|
return enterpriseLicense.split('__whitelabel')[0]
|
|
}
|
|
return undefined
|
|
})
|
|
export const workerTags = writable<string[] | undefined>(undefined)
|
|
// `undefined` while unresolved. `0` is a real usage value, so a placeholder that
|
|
// reads as one lets a failed or in-flight fetch render as "no executions used".
|
|
export const usageStore = writable<number | undefined>(undefined)
|
|
export const workspaceUsageStore = writable<number | undefined>(undefined)
|
|
export const initialArgsStore = writable<any>(undefined)
|
|
export const oauthStore = writable<TokenResponse | undefined>(undefined)
|
|
export const userStore = writable<UserExt | undefined>(undefined)
|
|
export const workspaceStore = writable<string | undefined>(
|
|
persistedWorkspace ? String(persistedWorkspace) : undefined
|
|
)
|
|
export const defaultScripts = writable<WorkspaceDefaultScripts | undefined>(undefined)
|
|
export const dbClockDrift = writable<number | undefined>(undefined)
|
|
// `undefined` until the active workspace's tier is known — a tier belongs to a
|
|
// workspace, so consumers rendering a number from it must not read the previous
|
|
// one's value across a switch. `false` is a claim, not a safe default: it meters a
|
|
// paid workspace against the free cap, so a failed fetch leaves this `undefined`.
|
|
export const isPremiumStore = writable<boolean | undefined>(undefined)
|
|
// Set when the tier fetch for the active workspace failed, which is indistinguishable
|
|
// from "still pending" in `isPremiumStore` alone.
|
|
export const premiumFetchFailed = writable<boolean>(false)
|
|
// For affordances rather than numbers: gate on this so a paid→paid switch doesn't
|
|
// retract a button for the length of the fetch, while a failed fetch still fails
|
|
// closed instead of leaving it enabled for the session.
|
|
export const maybePremium: Readable<boolean> = derived(
|
|
[isPremiumStore, premiumFetchFailed],
|
|
([premium, failed]) => premium !== false && !failed
|
|
)
|
|
// Bumped when the active workspace's membership is seen to have changed, so anything
|
|
// deriving a number from the member count (paid seats) can re-resolve it without
|
|
// polling or owning its own invalidation.
|
|
export const workspaceMembershipVersion = writable<number>(0)
|
|
export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(undefined)
|
|
// Stands in for `UserWorkspace.username` on entries with no `usr` row behind them. It
|
|
// names nobody, so anything that would address a user by it (a `u/<username>/...` path)
|
|
// must treat it as "no username here" rather than render it.
|
|
export const NON_MEMBER_USERNAME = 'superadmin'
|
|
export const superadmin = writable<string | false | undefined>(undefined)
|
|
export const devopsRole = writable<string | false | undefined>(undefined)
|
|
export const lspTokenStore = writable<string | undefined>(undefined)
|
|
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
|
|
export const wsBaseUrlStore = writable<string | undefined>(undefined)
|
|
export const disableHubStore = writable<boolean>(false)
|
|
// What a superadmin standing in a workspace they are not a member of needs to see it as a
|
|
// fork: the workspace itself and its parent. `listUserWorkspaces` is membership-gated, so
|
|
// `$userWorkspaces` would miss them and every fork lookup would read the workspace as a
|
|
// parentless root. Owned by exactly one workspace (`forWorkspace`) — held past a switch,
|
|
// these would go on presenting themselves as memberships (picker, workspace-family lookups).
|
|
export const nonMemberWorkspaces = writable<
|
|
{ forWorkspace: string; workspaces: UserWorkspace[] } | undefined
|
|
>(undefined)
|
|
|
|
/**
|
|
* Records what was resolved for `forWorkspace`, dropping archived workspaces —
|
|
* `userWorkspaces` must never offer one. An archived workspace therefore resolves to an
|
|
* empty set, which still marks it as resolved and stops it being fetched again.
|
|
*/
|
|
export function setNonMemberWorkspaces(forWorkspace: string, workspaces: Workspace[]): void {
|
|
nonMemberWorkspaces.set({
|
|
forWorkspace,
|
|
workspaces: workspaces
|
|
.filter((w) => !w.deleted)
|
|
.map((w) => ({
|
|
id: w.id,
|
|
name: w.name,
|
|
// No `usr` row here, hence no per-workspace username — same stand-in as the
|
|
// synthetic `admins` entry below.
|
|
username: NON_MEMBER_USERNAME,
|
|
color: w.color,
|
|
parent_workspace_id: w.parent_workspace_id,
|
|
is_dev_workspace: w.is_dev_workspace,
|
|
dev_workspace_label: w.dev_workspace_label,
|
|
disabled: false
|
|
}))
|
|
})
|
|
}
|
|
|
|
export function clearNonMemberWorkspaces(): void {
|
|
// A Svelte store notifies on every write of an object value, so clearing an already
|
|
// empty store is not free: the layout effect that fills this one also reads it, and
|
|
// would re-run itself forever.
|
|
if (get(nonMemberWorkspaces) != undefined) {
|
|
nonMemberWorkspaces.set(undefined)
|
|
}
|
|
}
|
|
|
|
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
|
|
[usersWorkspaceStore, superadmin, nonMemberWorkspaces],
|
|
([store, superadmin, nonMember]) => {
|
|
const originalWorkspaces = store?.workspaces ?? []
|
|
const workspaces = superadmin
|
|
? [
|
|
...originalWorkspaces.filter((x) => x.id != 'admins'),
|
|
{
|
|
id: 'admins',
|
|
name: 'Admins',
|
|
username: NON_MEMBER_USERNAME,
|
|
color: undefined,
|
|
operator_settings: undefined,
|
|
disabled: false
|
|
}
|
|
]
|
|
: originalWorkspaces
|
|
const extra = (nonMember?.workspaces ?? []).filter(
|
|
(w) => !workspaces.some((x) => x.id === w.id)
|
|
)
|
|
return extra.length > 0 ? [...workspaces, ...extra] : workspaces
|
|
}
|
|
)
|
|
|
|
export const codeCompletionLoading = writable<boolean>(false)
|
|
export const metadataCompletionEnabled = writable<boolean>(true)
|
|
export const stepInputCompletionEnabled = writable<boolean>(true)
|
|
export const FORMAT_ON_SAVE_SETTING_NAME = 'formatOnSave'
|
|
export const VIM_MODE_SETTING_NAME = 'vimMode'
|
|
export const RELATIVE_LINE_NUMBERS_SETTING_NAME = 'relativeLineNumbers'
|
|
export const CODE_COMPLETION_SETTING_NAME = 'codeCompletionSessionEnabled'
|
|
export const COPILOT_SESSION_MODEL_SETTING_NAME = 'copilotSessionModel'
|
|
export const COPILOT_SESSION_PROVIDER_SETTING_NAME = 'copilotSessionProvider'
|
|
export const COPILOT_SESSION_REASONING_SETTING_NAME = 'copilotSessionReasoning'
|
|
export const formatOnSave = writable<boolean>(
|
|
getLocalSetting(FORMAT_ON_SAVE_SETTING_NAME) != 'false'
|
|
)
|
|
export const vimMode = writable<boolean>(getLocalSetting(VIM_MODE_SETTING_NAME) == 'true')
|
|
export const relativeLineNumbers = writable<boolean>(
|
|
getLocalSetting(RELATIVE_LINE_NUMBERS_SETTING_NAME) == 'true'
|
|
)
|
|
export const codeCompletionSessionEnabled = writable<boolean>(
|
|
getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false'
|
|
)
|
|
|
|
export const AI_USER_DISABLED_SETTING_NAME = 'aiUserDisabled'
|
|
// Master per-user (per-device) opt-out for all Windmill AI features. Initialized at
|
|
// module load so it applies on startup, not only once the settings panel mounts.
|
|
export const aiUserDisabled = writable<boolean>(
|
|
getLocalSetting(AI_USER_DISABLED_SETTING_NAME) === 'true'
|
|
)
|
|
|
|
export const usedTriggerKinds = writable<string[]>([])
|
|
|
|
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
|
|
/** Read-only S3 file browser (S3FilePicker instance) mounted in the logged
|
|
* layout, used by Explore buttons in contexts that don't wire their own picker
|
|
* instance. Typed loosely because the component instance type resolves
|
|
* differently in .ts and .svelte contexts. */
|
|
export let globalS3FilePickerExplorer: StateStore<
|
|
{ open: (fileKey?: any, opts?: { s3ResourcePath?: string }) => Promise<void> } | undefined
|
|
> = createState({
|
|
val: undefined
|
|
})
|
|
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = createState({
|
|
val: undefined
|
|
})
|
|
|
|
export type GlobalForkModalState = {
|
|
opened: true
|
|
}
|
|
|
|
export type ForkConflictModalState = {
|
|
kind: string
|
|
kindLabel: string
|
|
parentWorkspaceId: string
|
|
resolve: (proceed: boolean) => void
|
|
}
|
|
|
|
export let forkConflictModal: StateStore<ForkConflictModalState | undefined> = createState({
|
|
val: undefined
|
|
})
|
|
|
|
type SQLBaseSchema = {
|
|
[schemaKey: string]: {
|
|
[tableKey: string]: {
|
|
[columnKey: string]: {
|
|
type: string
|
|
default: string
|
|
required: boolean
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const SQLSchemaLanguages = [
|
|
'mysql',
|
|
'bigquery',
|
|
'postgresql',
|
|
'snowflake',
|
|
'mssql',
|
|
'oracledb'
|
|
] as const
|
|
|
|
export interface SQLSchema {
|
|
lang: (typeof SQLSchemaLanguages)[number] | 'ducklake'
|
|
schema: SQLBaseSchema
|
|
publicOnly: boolean | undefined
|
|
stringified: string
|
|
/** MySQL only: the connection's default database (`DATABASE()`), surfaced by the
|
|
* introspection script. Lets the table picker render the default db's tables
|
|
* unprefixed even when the connection can also see other (non-system) schemas. */
|
|
defaultDb?: string
|
|
}
|
|
|
|
export interface GraphqlSchema {
|
|
lang: 'graphql'
|
|
schema: IntrospectionQuery
|
|
stringified: string
|
|
}
|
|
|
|
export type DBSchema = SQLSchema | GraphqlSchema
|
|
|
|
export type DBSchemas = Partial<Record<string, DBSchema>>
|
|
|
|
export const dbSchemas = writable<DBSchemas>({})
|
|
|
|
export const instanceSettingsSelectedTab = writable('users')
|
|
|
|
export const isCriticalAlertsUIOpen = writable(false)
|
|
|
|
let getWorkspacePromise: CancelablePromise<Workspace> | null = null
|
|
export const workspaceColor: Readable<string | null | undefined> = derived(
|
|
[workspaceStore, usersWorkspaceStore, superadmin],
|
|
([workspaceStore, usersWorkspaceStore, superadmin], set: (value: string | undefined) => void) => {
|
|
if (!workspaceStore || !usersWorkspaceStore) {
|
|
return
|
|
}
|
|
|
|
// First try to get the color from usersWorkspaceStore
|
|
const workspace = usersWorkspaceStore.workspaces.find((w) => w.id === workspaceStore)
|
|
|
|
if (workspace) {
|
|
set(workspace.color)
|
|
return
|
|
}
|
|
|
|
// If workspace not found and user is superadmin, get it as superadmin
|
|
if (!superadmin) {
|
|
set(undefined)
|
|
return
|
|
}
|
|
|
|
getWorkspacePromise?.cancel()
|
|
|
|
getWorkspacePromise = WorkspaceService.getWorkspaceAsSuperAdmin({
|
|
workspace: workspaceStore
|
|
})
|
|
|
|
getWorkspacePromise
|
|
.then((workspace) => set(workspace.color))
|
|
.catch((error) => {
|
|
console.error('error getting workspace as superadmin', error)
|
|
set(undefined)
|
|
})
|
|
}
|
|
)
|
|
|
|
export const isCurrentlyInTutorial: StateStore<boolean> = createState({ val: false })
|
|
|
|
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
|
|
const schema = dbSchema?.schema ?? {}
|
|
const tableNames: string[] = []
|
|
|
|
for (const schemaKey in schema) {
|
|
for (const tableKey in schema[schemaKey]) {
|
|
tableNames.push(`${schemaKey}.${tableKey}`)
|
|
}
|
|
}
|
|
|
|
return tableNames
|
|
}
|