Files
windmill/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts
T
hugocasa 11fda89b52 feat(telemetry): generic feature-usage telemetry with AI session metrics (#10200)
* feat(telemetry): add generic feature_usage table and batched logging endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(telemetry): log AI session usage events and document them in telemetry settings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): use escape sequence instead of literal NUL bytes in buffer key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): validate dimensions, decouple retention, keepalive flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): allowlist feature-usage dimensions and index retention scans

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): pin tool-name allowlist and deploy session attribution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(telemetry): route AI chat usage through feature_usage and drop ai_chat_usage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(telemetry): slim dimension validation to registered kinds plus key shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): backfill ai_chat_usage into feature_usage before dropping it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): disclose provider and model identifiers in telemetry settings text

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): issue all flush chunks before awaiting so pagehide keeps them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 6306c072a50937ea9af44a5bcf42345543207486

This commit updates the EE repository reference after PR #672 was merged in windmill-ee-private.

Previous ee-repo-ref: 964f242a0eb44db7f7d26636cc8d76aeabea2b73

New ee-repo-ref: 6306c072a50937ea9af44a5bcf42345543207486

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-07-20 20:56:57 +02:00

362 lines
12 KiB
TypeScript

import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte'
import {
checkDeployPermission,
checkItemExists,
getItemValue,
type DeployPermission,
type DeployResult
} from '$lib/utils_workspace_deploy'
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'
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>
}
export interface DiffValues {
before: unknown
after: unknown
}
/** 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) =>
checkItemExists(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 (operator / RestrictDeployToDeployers)
// 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 })
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 }
void checkDeployPermission(ws).then((perm) => {
if (deployPermFetchedFor === ws) deployPerm = perm
})
})
})
// ── 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':
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 blocked by the operator / deployer rule) — the UI
// disables it too; this is the guard behind that.
if (!discard && (!item.canWrite || !deployPerm.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 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. */
get deployPermission(): DeployPermission {
return deployPerm
},
deployRow,
discardRow
}
}
export type SessionDeployModel = ReturnType<typeof useSessionDeployModel>