diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index 7d07b9132a..40fb2fa235 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-37695a769b25d16b34107eedc1076793a8b388c8
+c6902ec2c51dc0ce30962afbfab3e456c5d9b831
diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md
index 947329fc45..57467e25ec 100644
--- a/docs/feature-telemetry.md
+++ b/docs/feature-telemetry.md
@@ -4,9 +4,10 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
-It currently carries 20 registered actions across eight features (`ai_session`, `ai_chat`,
-`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`). Nearly all of
-the product is uninstrumented, so new user-facing work is the opportunity to change that.
+It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`,
+`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`,
+`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the
+opportunity to change that.
## When to instrument
diff --git a/frontend/src/lib/components/DropdownSubmenuItem.svelte b/frontend/src/lib/components/DropdownSubmenuItem.svelte
index 631f6374bd..60f7ad2d64 100644
--- a/frontend/src/lib/components/DropdownSubmenuItem.svelte
+++ b/frontend/src/lib/components/DropdownSubmenuItem.svelte
@@ -52,51 +52,71 @@
{#if subItem.separatorTop}
+ {EXECUTIONS_HINT} Counters reset at the start of every calendar month.
+
+ {#if $isPremiumStore}
+
+ Your {seats} seat{seats === 1 ? '' : 's'} include {fmt(
+ (seats ?? 0) * SEAT_EXECUTION_QUOTA
+ )} executions per month. Every extra {fmt(SEAT_EXECUTION_QUOTA)} executions beyond that add
+ one billed seat for the month.
+
+ {:else}
+
+ Either quota reaching {fmt(FREE_EXECUTION_QUOTA)} stops jobs from running for the rest of the
+ month. Team and Enterprise plans lift both limits.
+ {#if !$userStore?.is_admin}
+ Ask a workspace admin to change the plan.
+ {/if}
+
+ {/if}
+
+ {#snippet actions()}
+ {#if $userStore?.is_admin}
+
+ {/if}
+ {/snippet}
+
+{/if}
diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte
index 1ec649cf12..43440249ae 100644
--- a/frontend/src/lib/components/sidebar/UserMenu.svelte
+++ b/frontend/src/lib/components/sidebar/UserMenu.svelte
@@ -14,8 +14,9 @@
import { Crown, ServerCog, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import MenuButton from './MenuButton.svelte'
- import { Menu, MenuItem } from '$lib/components/meltComponents'
+ import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { type MenubarBuilders } from '@melt-ui/svelte'
+ import { EXECUTIONS_HINT } from './executionsHint'
let darkMode: boolean = $state(false)
@@ -103,27 +104,41 @@
- {#if isCloudHosted()}
+
+ {#if isCloudHosted() && $isPremiumStore !== undefined}
{/if}
diff --git a/frontend/src/lib/components/sidebar/executionsHint.ts b/frontend/src/lib/components/sidebar/executionsHint.ts
new file mode 100644
index 0000000000..6530a6a6cd
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/executionsHint.ts
@@ -0,0 +1,7 @@
+export const FREE_EXECUTION_QUOTA = 1000
+
+/** Executions each paid seat includes per month (mirrors the billing page). */
+export const SEAT_EXECUTION_QUOTA = 10000
+
+export const EXECUTIONS_HINT =
+ 'An execution is one second of compute, not one job run: a job counts as 1 execution, plus 1 more for each additional second it runs.' as const
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts
index 0058cc8dd0..8710b6d20b 100644
--- a/frontend/src/lib/stores.ts
+++ b/frontend/src/lib/stores.ts
@@ -80,8 +80,10 @@ export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLice
return undefined
})
export const workerTags = writable(undefined)
-export const usageStore = writable(0)
-export const workspaceUsageStore = writable(0)
+// `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(undefined)
+export const workspaceUsageStore = writable(undefined)
export const initialArgsStore = writable(undefined)
export const oauthStore = writable(undefined)
export const userStore = writable(undefined)
@@ -90,7 +92,25 @@ export const workspaceStore = writable(
)
export const defaultScripts = writable(undefined)
export const dbClockDrift = writable(undefined)
-export const isPremiumStore = writable(false)
+// `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(undefined)
+// Set when the tier fetch for the active workspace failed, which is indistinguishable
+// from "still pending" in `isPremiumStore` alone.
+export const premiumFetchFailed = writable(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 = 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(0)
export const usersWorkspaceStore = writable(undefined)
export const superadmin = writable(undefined)
export const devopsRole = writable(undefined)
diff --git a/frontend/src/lib/usage.svelte.ts b/frontend/src/lib/usage.svelte.ts
new file mode 100644
index 0000000000..d64934e94b
--- /dev/null
+++ b/frontend/src/lib/usage.svelte.ts
@@ -0,0 +1,99 @@
+import { resource } from 'runed'
+import { UserService, WorkspaceService } from '$lib/gen'
+import { isCloudHosted } from '$lib/cloud'
+import { scopedValue, tagged } from '$lib/utils/scopedValue'
+import {
+ isPremiumStore,
+ premiumFetchFailed,
+ usageStore,
+ workspaceUsageStore,
+ type UserExt
+} from '$lib/stores'
+
+/**
+ * The cloud execution counters and the workspace's plan tier. Call once, at layout init:
+ * these are app-wide values, and the logged-in layout outlives every in-app navigation.
+ */
+export function createUsageResources(args: {
+ workspace: () => string | undefined
+ user: () => UserExt | undefined
+}) {
+ // All three need an authenticated membership, so they key on the user being loaded
+ // *for this workspace* — a switch must not fire them against the workspace we left.
+ const readyWorkspace = () => {
+ const workspace = args.workspace()
+ if (!isCloudHosted() || !workspace) return undefined
+ return args.user()?.workspace_id === workspace ? workspace : undefined
+ }
+ // The user counter is account-wide, so its key is the account: a workspace switch
+ // is not a change of key and must not re-fetch or clear it.
+ const readyUser = () => (isCloudHosted() ? args.user()?.email : undefined)
+
+ // `Number(...)`: both usage endpoints serve text/plain, so the client hands back a
+ // string despite the generated `number` type. Interpolation and arithmetic coerce
+ // it, but `toLocaleString` on a string returns it unchanged — the thousands
+ // separator would silently go missing above 999.
+ const fetchWorkspaceExecutions = tagged(async (workspace: string) =>
+ Number(await WorkspaceService.getWorkspaceUsage({ workspace }))
+ )
+ const fetchUserExecutions = tagged(async (_email: string) => Number(await UserService.getUsage()))
+ const fetchPremium = tagged((workspace: string) => WorkspaceService.getIsPremium({ workspace }))
+
+ const workspaceExecutions = resource(readyWorkspace, async (workspace) =>
+ workspace ? await fetchWorkspaceExecutions(workspace) : undefined
+ )
+
+ const userExecutions = resource(readyUser, async (email) =>
+ email ? await fetchUserExecutions(email) : undefined
+ )
+
+ const premium = resource(readyWorkspace, async (workspace) =>
+ workspace ? await fetchPremium(workspace) : undefined
+ )
+
+ const scopedWorkspaceExecutions = scopedValue()
+ const scopedUserExecutions = scopedValue()
+ const scopedPremium = scopedValue()
+
+ // The only place any of this reaches a store. `undefined` until a value for the
+ // active scope has arrived — never a stand-in like `0` or `false`, both of which are
+ // legal values a consumer would render as real.
+ $effect(() => {
+ workspaceUsageStore.set(
+ scopedWorkspaceExecutions(args.workspace(), workspaceExecutions.current)
+ )
+ })
+
+ $effect(() => {
+ usageStore.set(scopedUserExecutions(args.user()?.email, userExecutions.current))
+ })
+
+ $effect(() => {
+ const tier = scopedPremium(args.workspace(), premium.current)
+ isPremiumStore.set(tier)
+ // Only a failure that left us with no tier for this workspace counts: a late
+ // rejection for a workspace we left must not retract affordances here.
+ premiumFetchFailed.set(!!premium.error && tier === undefined)
+ })
+
+ return {
+ /** Re-reads the counters. Executions accrue continuously, so anything displaying
+ * them needs this — the workspace-change refetch alone leaves an open tab stale. */
+ refreshExecutions() {
+ void workspaceExecutions.refetch()
+ void userExecutions.refetch()
+ }
+ }
+}
+
+// Registered by the layout so components can ask for a re-read without owning the
+// resources or reaching back into the layout.
+let handle: ReturnType | undefined = undefined
+
+export function registerUsageResources(h: ReturnType): void {
+ handle = h
+}
+
+export function refreshExecutions(): void {
+ handle?.refreshExecutions()
+}
diff --git a/frontend/src/lib/utils/scopedValue.test.ts b/frontend/src/lib/utils/scopedValue.test.ts
new file mode 100644
index 0000000000..202547693c
--- /dev/null
+++ b/frontend/src/lib/utils/scopedValue.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest'
+import { scopedValue, tagged } from './scopedValue'
+
+// The ordering these assert is the one `resource` does not provide, and the one whose
+// absence produced the stale-workspace and A→B→A defects this guard replaced.
+describe('scopedValue', () => {
+ it('holds a value only for the key it describes', () => {
+ const held = scopedValue()
+ expect(held('a', undefined)).toBe(undefined)
+ expect(held('a', { key: 'a', seq: 1, value: 1 })).toBe(1)
+ // Switched to b, nothing fetched for it yet: a's value must not stand in.
+ expect(held('b', { key: 'a', seq: 1, value: 1 })).toBe(undefined)
+ expect(held('b', { key: 'b', seq: 2, value: 2 })).toBe(2)
+ })
+
+ it('ignores an answer for a scope we left, instead of publishing or erasing', () => {
+ const held = scopedValue()
+ held('a', { key: 'a', seq: 1, value: 1 })
+ held('b', { key: 'b', seq: 2, value: 2 })
+ // A's slow response lands after B resolved: neither replaces B's value nor blanks it.
+ expect(held('b', { key: 'a', seq: 1, value: 99 })).toBe(2)
+ })
+
+ it('ignores an answer overtaken by a newer one for the same key', () => {
+ const held = scopedValue()
+ // Two fetches for one key — a refetch landing on an in-flight load, or a second
+ // invalidation — resolving inverted. The later-issued value must win.
+ expect(held('a', { key: 'a', seq: 2, value: 20 })).toBe(20)
+ expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(20)
+ })
+
+ it('keeps the value across a re-read of the same key', () => {
+ const held = scopedValue()
+ held('a', { key: 'a', seq: 1, value: 1 })
+ // A refetch leaves the previous value in place until the new one lands, so the
+ // display never blanks mid-refresh.
+ expect(held('a', { key: 'a', seq: 1, value: 1 })).toBe(1)
+ expect(held('a', { key: 'a', seq: 2, value: 5 })).toBe(5)
+ })
+
+ it('treats returning to a key as unknown until it is fetched again', () => {
+ const held = scopedValue()
+ held('a', { key: 'a', seq: 1, value: 1 })
+ held('b', { key: 'b', seq: 2, value: 2 })
+ expect(held('a', undefined)).toBe(undefined)
+ })
+
+ it('orders a late answer against the read issued on returning to its key', () => {
+ const held = scopedValue()
+ // A's first read is still in flight when we leave for B, so nothing for A is held.
+ expect(held('b', { key: 'b', seq: 2, value: 2 })).toBe(2)
+ // Back on A, that late answer is the only value describing A, so it stands...
+ expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(10)
+ // ...until the read issued on returning lands, and cannot come back afterwards.
+ expect(held('a', { key: 'a', seq: 3, value: 30 })).toBe(30)
+ expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(30)
+ })
+
+ it('stamps issue order even when responses resolve inverted', async () => {
+ const settle: Array<(v: number) => void> = []
+ const fetch = tagged((_key: string) => new Promise((r) => settle.push(r)))
+ const first = fetch('a')
+ const second = fetch('a')
+ // Resolve the second request first, then the first: the seq must reflect the
+ // order they were *issued*, not the order they came back.
+ settle[1](20)
+ settle[0](10)
+ expect(await first).toEqual({ key: 'a', seq: 1, value: 10 })
+ expect(await second).toEqual({ key: 'a', seq: 2, value: 20 })
+
+ const held = scopedValue()
+ expect(held('a', await second)).toBe(20)
+ expect(held('a', await first)).toBe(20)
+ })
+})
diff --git a/frontend/src/lib/utils/scopedValue.ts b/frontend/src/lib/utils/scopedValue.ts
new file mode 100644
index 0000000000..86bdfb8a7d
--- /dev/null
+++ b/frontend/src/lib/utils/scopedValue.ts
@@ -0,0 +1,33 @@
+export type Tagged = { key: string; seq: number; value: T }
+
+/**
+ * Stamps each result with the scope it describes and the order its request was issued in.
+ * `resource` orders nothing: it assigns `current` unconditionally on resolve, and cancels
+ * through an `AbortSignal` the generated client cannot consume. The scope alone cannot
+ * order two requests for one scope, so the issue order travels alongside it.
+ */
+export function tagged(
+ fetch: (key: K) => Promise
+): (key: K) => Promise> {
+ let issued = 0
+ return async (key: K) => {
+ const seq = ++issued
+ return { key, seq, value: await fetch(key) }
+ }
+}
+
+/**
+ * Holds the newest value fetched for `key`; a late answer for a scope we left, or one
+ * overtaken for this scope, neither publishes nor erases. A failed refresh leaves the
+ * last successful value standing, which is why `loading` cannot gate this: it is true
+ * throughout a re-read whose held value is still the right one to show.
+ */
+export function scopedValue() {
+ let held: Tagged | undefined = undefined
+ return (key: string | undefined, fetched: Tagged | undefined) => {
+ if (fetched && fetched.key === key && (held?.key !== key || fetched.seq > held.seq)) {
+ held = fetched
+ }
+ return held && held.key === key ? held.value : undefined
+ }
+}
diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte
index ead6fa6d8d..e55cfd54a9 100644
--- a/frontend/src/routes/(root)/(logged)/+layout.svelte
+++ b/frontend/src/routes/(root)/(logged)/+layout.svelte
@@ -16,6 +16,7 @@
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte'
+ import SidebarUsage from '$lib/components/sidebar/SidebarUsage.svelte'
import SidebarScrollArea from '$lib/components/sidebar/SidebarScrollArea.svelte'
import { SIDEBAR_BG, SIDEBAR_BG_DARK } from '$lib/components/sidebar/sidebarChrome'
import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte'
@@ -23,10 +24,7 @@
import UpdateDevWorkspaceModal from '$lib/components/UpdateDevWorkspaceModal.svelte'
import {
enterpriseLicense,
- isPremiumStore,
superadmin,
- usageStore,
- workspaceUsageStore,
userStore,
workspaceStore,
userWorkspaces,
@@ -72,6 +70,7 @@
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import MenuLink from '$lib/components/sidebar/MenuLink.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
+ import { createUsageResources, registerUsageResources } from '$lib/usage.svelte'
import { purgeLegacyUserDrafts } from '$lib/userDraftLegacyMigration'
import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration'
import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte'
@@ -108,6 +107,15 @@
let { children }: Props = $props()
OpenAPI.WITH_CREDENTIALS = true
+ // Owned here because the logged-in layout is the app's lifetime: it outlives every
+ // in-app navigation, so the counters and tier resolve once per workspace rather than
+ // per mounting component, and no detached `$effect.root` is needed to hold them.
+ registerUsageResources(
+ createUsageResources({
+ workspace: () => $workspaceStore,
+ user: () => $userStore
+ })
+ )
let menuOpen = $state(false)
// Set by the workspace⇄session switch before it navigates, so the mobile menu
// drawer stays open across a mode toggle (unlike a normal link navigation,
@@ -327,16 +335,6 @@
} catch (e) {
console.error('Could not persist username to local storage', e)
}
- // Populate for all members (not just admins) so non-admin developers also get premium-gated
- // affordances like the fork entry points on cloud. The `is_premium` endpoint is a boolean
- // and no longer admin-gated. Best-effort: a failure here must not block user-store init.
- if (isCloudHosted()) {
- try {
- isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace }))
- } catch (e) {
- console.error('Could not fetch premium status', e)
- }
- }
} else {
userStore.set(undefined)
}
@@ -460,7 +458,6 @@
function onLoad() {
loadFavorites()
- loadUsage()
syncTutorialsTodos()
loadHubBaseUrl()
loadWsBaseUrl()
@@ -468,15 +465,6 @@
loadUsedTriggerKinds()
}
- async function loadUsage() {
- if (isCloudHosted() && $workspaceStore) {
- $usageStore = await UserService.getUsage()
- $workspaceUsageStore = await WorkspaceService.getWorkspaceUsage({
- workspace: $workspaceStore!
- })
- }
- }
-
async function loadHubBaseUrl() {
$hubBaseUrlStore =
((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) ||
@@ -1090,6 +1078,10 @@
{/if}
+