Files
windmill/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts
T
GuilhemandClaude Opus 5 338c4e921d feat: operators get AI sessions (#11443)
* 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>
2026-10-09 18:22:34 +02:00

418 lines
15 KiB
TypeScript

import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import { get } from 'svelte/store'
import { userWorkspaces } from '$lib/stores'
import {
checkDeployPermission,
checkDeployRules,
checkItemExists,
deployPermissionForKind,
getItemValue,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
import type { Kind } from '$lib/utils_deployable'
import {
deployDraft,
discardDraft,
fetchDraftBaseStale,
getDraftDiffValues
} from '$lib/utils_draft_deploy'
import { untrack } from 'svelte'
import {
buildDeployItems,
deployPlanFor,
diffBaseFor,
discardPlanFor,
maskOnlyCandidates,
type DeployItem,
type DeployPlanEntry
} from './sessionDeployModel'
import { maskKey } from './modifiedItemsMask'
import { sessionState } from './sessionState.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { AssetService } from '$lib/gen'
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
export type DeploymentStatus = { status: 'loading' | 'failed'; error?: string }
// Reactive wrapper over the pure sessionDeployModel: owns the async draft list
// and builds the unified item list. The diff-value loader lives here too so both
// the tree and the diff column read one resolver. The dock is granular per-item
// deploy (deployRow/discardRow); batch flows and fork→parent promotion are the
// compare page's concern.
export interface SessionDeployModelArgs {
workspaceId: string
/** Called after a deploy/discard mutated workspace state. Lets the consumer
* refresh ITS OWN data sources — without this, the bar shows stale counts
* until the next turn end / tab refocus / reload. */
onDataChanged?: () => void
/** A draft deployed: the consumer owning the mask moves the item's entry to
* its deployed path (`displayPath` — differs from the synthetic storage
* `path` for draft-only items, which never exists deployed). */
onItemDeployed?: (item: DeployItem) => void
/** A draft was discarded: the chat's touch is undone — the consumer drops
* the item's mask entry so a pre-existing deployed item doesn't keep
* reading as this chat's "Deployed" edit. */
onItemDiscarded?: (item: DeployItem) => void
/** Chat-modified-items mask; undefined shows every draft. */
mask?: Set<string>
/** Deploys a pipeline folder's draft bundle, which no per-item deploy can: it
* expands into the folder's scripts and triggers. */
deployPipeline?: (folder: string) => Promise<DeployResult>
}
export interface DiffValues {
before: unknown
after: unknown
}
// A pipeline has no row of its own: it exists while its folder holds pipeline scripts.
async function itemExists(kind: Kind, path: string, workspace: string): Promise<boolean> {
if (kind !== PIPELINE_DRAFT_KIND) return checkItemExists(kind, path, workspace)
const folder = pipelineFolderFromBundlePath(path)
if (!folder) return false
const pipelines = await AssetService.listPipelineFolders({ workspace })
return pipelines.some((p) => p.folder === folder)
}
/** Resolves which mask-only candidates still exist in the session workspace
* (deployed) vs are gone (discarded drafts / deleted items) — the
* `existingKeys` input of `buildDeployItems`. Undefined while resolving, so
* deployed rows only appear once confirmed. Shared by the drawer model and the
* bar's dock counts. */
export function useExistingMaskKeys(
getInput: () => Omit<Parameters<typeof buildDeployItems>[0], 'existingKeys'> & {
workspaceId: string
}
) {
let existingKeys = $state<Set<string> | undefined>(undefined)
// Existence is checked once per distinct candidate set; the signature avoids
// re-firing while results stream back.
let lastCandidateSig = ''
// Bumped by refresh() to force a re-check even when the candidate set (the
// effect's other dependency) is unchanged.
let recheck = $state(0)
$effect(() => {
void recheck
const { workspaceId, ...buildInput } = getInput()
const cands = maskOnlyCandidates(buildInput)
// The workspace is part of the identity: the same candidate keys in a
// different workspace must re-check, not reuse the previous verdict.
const sig =
workspaceId +
'|' +
cands
.map((c) => c.key)
.sort()
.join(',')
untrack(() => {
if (sig === lastCandidateSig) return
lastCandidateSig = sig
if (cands.length === 0) {
existingKeys = new Set()
return
}
// A mask-only item that was deployed still exists in the session
// workspace; a discarded or deleted one doesn't.
void Promise.all(
cands.map((c) =>
itemExists(c.deployKind, c.path, workspaceId)
.then((exists) => (exists ? c.key : null))
.catch(() => null)
)
).then((keys) => {
// A slower batch for a superseded candidate set (or one voided by
// reset()) must not overwrite the current result.
if (sig !== lastCandidateSig) return
existingKeys = new Set(keys.filter((k): k is string => !!k))
})
})
})
return {
get keys() {
return existingKeys
},
/** Force a fresh existence check (an item may have been deleted since).
* Hard invalidate: current keys are dropped until the check resolves, so
* rows derived from them disappear meanwhile — use on a fresh surface
* (drawer open), not on a live one. */
reset() {
lastCandidateSig = ''
existingKeys = undefined
},
/** Re-check while keeping the current keys (stale-while-revalidate), so a
* live surface doesn't blank out during the round-trip. */
refresh() {
lastCandidateSig = ''
recheck++
}
}
}
export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) {
let draftItems = $state<DraftItem[]>([])
let loading = $state(false)
let error = $state<string | undefined>(undefined)
// Mask keys confirmed to still exist → allowed to show as terminal deployed
// rows (a discarded draft is a mask entry with no item, so it stays out).
const existing = useExistingMaskKeys(() => ({
draftItems,
mask: getArgs().mask,
workspaceId: getArgs().workspaceId
}))
// Optimistic knowledge from this model's own deploys: a deployed key exists
// in the session workspace before the async existence check confirms it.
// Without this a deployed row falls out of the list for ~1s, unmounting its
// card.
let deployedKeys = $state<ReadonlySet<string>>(new Set())
const items = $derived.by(() => {
const existingKeys =
deployedKeys.size === 0 ? existing.keys : new Set([...(existing.keys ?? []), ...deployedKeys])
return buildDeployItems({ draftItems, mask: getArgs().mask, existingKeys })
})
async function fetchDrafts() {
loading = true
error = undefined
try {
draftItems = await getDraftItems(getArgs().workspaceId)
} catch (e) {
console.error('Session deploy model: draft list failed', e)
error = `Failed to load drafts: ${e}`
draftItems = []
} finally {
loading = false
}
}
/** (Re)fetch. Called on open. */
function load() {
// Re-check existence fresh (an item may have been deleted since last open).
existing.reset()
// Drop the optimistic bridge with it: it only covers the gap until the
// fresh check resolves, and a stale entry would keep a since-deleted item
// reading as deployed forever.
deployedKeys = new Set()
// Staleness can change between opens (a deploy elsewhere moves the head).
staleFetched.clear()
staleKeys = new Set()
void fetchDrafts()
}
// ── Stale drafts (base ≠ deployed head) ──────────────────────────────────
// A draft forked from a version that is no longer the deployed head: someone
// deployed in between, so deploying the draft would silently revert them.
// Same detection as the compare page; surfaced as a warning, deploy stays
// allowed. Only own drafts on deployed script/flow/app items carry a base.
let staleKeys = $state<ReadonlySet<string>>(new Set())
const staleFetched = new Set<string>()
$effect(() => {
const list = items
const ws = getArgs().workspaceId
untrack(() => {
for (const it of list) {
if (!it.hasDraft || it.draftOnly || !it.mine) continue
if (!['script', 'flow', 'app', 'raw_app'].includes(it.draftKind)) continue
if (staleFetched.has(it.key)) continue
staleFetched.add(it.key)
void fetchDraftBaseStale(it.draftKind, it.path, ws).then((stale) => {
if (stale) staleKeys = new Set(staleKeys).add(it.key)
})
}
})
})
// ── Deploy permission ────────────────────────────────────────────────────
// Preflight the shared checkDeployPermission for the session workspace so the
// button disables with a reason instead of failing on click. `ok` defaults
// true while resolving (fail-open).
let deployPerm = $state<DeployPermission>({ ok: true })
// `checkDeployPermission` refuses every operator, but flows.rs lets one with the
// workspace's `builder_flows` right deploy flows, under the rules alone.
let builderFlowPerm = $state<DeployPermission | undefined>(undefined)
let deployPermFetchedFor = ''
$effect(() => {
const ws = getArgs().workspaceId
untrack(() => {
if (!ws || deployPermFetchedFor === ws) return
deployPermFetchedFor = ws
// Reset to fail-open for the new workspace and drop a stale resolution
// (a slower fetch for the previous workspace must not gate this one).
deployPerm = { ok: true }
builderFlowPerm = undefined
void checkDeployPermission(ws).then(async (perm) => {
if (deployPermFetchedFor !== ws) return
deployPerm = perm
const builder =
get(userWorkspaces).find((w) => w.id === ws)?.operator_settings?.builder_flows === true
if (perm.refusedBy === 'operator' && builder) {
const rules = await checkDeployRules(ws)
if (deployPermFetchedFor === ws) builderFlowPerm = rules
}
})
})
})
function permissionFor(kind: Kind): DeployPermission {
if (kind === 'flow' && builderFlowPerm) return deployPermissionForKind(builderFlowPerm, kind)
return deployPermissionForKind(deployPerm, kind)
}
// ── Deploy execution ─────────────────────────────────────────────────────
// Per-item transient deploy state (keyed by DeployItem.key): loading or
// failed. Success has no status — the row's badge flipping to Deployed is
// the feedback. Reassigned, not mutated.
let deploymentStatus = $state<Record<string, DeploymentStatus>>({})
let deploying = $state(false)
function setStatus(key: string, s: DeploymentStatus | undefined) {
const next = { ...deploymentStatus }
if (s) next[key] = s
else delete next[key]
deploymentStatus = next
}
async function runPlan(entry: DeployPlanEntry): Promise<DeployResult> {
const cur = getArgs().workspaceId
switch (entry.op) {
case 'deploy_draft': {
const folder =
entry.draftKind === PIPELINE_DRAFT_KIND
? pipelineFolderFromBundlePath(entry.path)
: undefined
const deployPipeline = getArgs().deployPipeline
if (folder && deployPipeline) return deployPipeline(folder)
return deployDraft(entry.draftKind, entry.path, cur, {
draftOnly: entry.draftOnly,
rawApp: entry.rawApp
})
}
case 'discard':
return discardDraft(entry.draftKind, entry.path, cur, entry.draftOnly, entry.legacy)
}
}
function refreshData() {
void fetchDrafts()
getArgs().onDataChanged?.()
}
/** Deploy (or discard) a single item; returns whether it succeeded. */
async function deployOne(item: DeployItem, discard = false): Promise<boolean> {
const plan = discard ? discardPlanFor(item) : deployPlanFor(item)
if (!plan) return false
// Snapshot before the await: the user may switch sessions while the
// deploy runs, and the event belongs to the initiating session.
const initiatingSessionId = sessionState.currentSessionId
// Don't attempt a deploy we know the user can't make (no write permission on
// the path, or refused by the preflight for this kind) — the UI disables it
// too; this is the guard behind that.
if (!discard && (!item.canWrite || !permissionFor(item.deployKind).ok)) return false
setStatus(item.key, { status: 'loading' })
deploying = true
try {
const res = await runPlan(plan)
setStatus(item.key, res.success ? undefined : { status: 'failed', error: res.error })
if (res.success) {
if (discard) {
getArgs().onItemDiscarded?.(item)
} else {
// Bridge under the DEPLOYED path's key too: the mask rewrite
// (onItemDeployed) re-keys a synthetic-storage row to displayPath.
deployedKeys = new Set(deployedKeys)
.add(item.key)
.add(maskKey(item.draftKind, item.displayPath))
getArgs().onItemDeployed?.(item)
logFeatureUsage('ai_session', 'deployed', {
key: item.draftKind,
entityId: initiatingSessionId,
workspace: getArgs().workspaceId
})
}
}
return res.success
} finally {
deploying = false
}
}
/** Returns success so the UI can stage its post-deploy animation. */
async function deployRow(item: DeployItem): Promise<boolean> {
const ok = await deployOne(item)
if (ok) refreshData()
return ok
}
async function discardRow(item: DeployItem) {
if (await deployOne(item, true)) refreshData()
}
// ── Diff values (one resolver for tree + column) ─────────────────────────
async function loadDiffValues(item: DeployItem): Promise<DiffValues> {
const pipelineFolder =
item.draftKind === PIPELINE_DRAFT_KIND ? pipelineFolderFromBundlePath(item.path) : undefined
if (pipelineFolder) {
// Dynamic: the pipeline deploy module reaches every trigger kind's save utils.
const { loadPipelineDraftDiff } = await import(
'$lib/components/assets/AssetGraph/pipelineDeploy.svelte'
)
if (item.hasDraft) return loadPipelineDraftDiff(getArgs().workspaceId, pipelineFolder)
return { before: undefined, after: undefined }
}
const base = diffBaseFor(item, getArgs().workspaceId)
if (base.kind === 'draft') {
const { deployed, draft } = await getDraftDiffValues(
base.draftKind,
item.path,
base.workspaceId,
base.draftOnly
)
return { before: base.draftOnly ? undefined : deployed, after: draft }
}
const v = await getItemValue(base.deployKind, base.path, base.workspaceId).catch(
() => undefined
)
return { before: v, after: v }
}
return {
get items() {
return items
},
get loading() {
return loading
},
get error() {
return error
},
load,
loadDiffValues,
get deploying() {
return deploying
},
statusOf(key: string): DeploymentStatus | undefined {
return deploymentStatus[key]
},
/** Whether a draft row's base is stale (deployed head moved since). */
staleOf(key: string): boolean {
return staleKeys.has(key)
},
/**
* Whether the user may deploy into the session workspace. Per-kind, because a
* direct-deployment lock never reaches schedules or triggers server-side — a row of
* that kind stays deployable while a script row does not.
*/
deployPermissionForKind(kind: Kind): DeployPermission {
return permissionFor(kind)
},
deployRow,
discardRow
}
}
export type SessionDeployModel = ReturnType<typeof useSessionDeployModel>