mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
11fda89b52
* 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>
362 lines
12 KiB
TypeScript
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>
|