Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cfb2b68
Add AI feature flag configuration
cstns May 20, 2026
465ae8f
Conditionally enable `generatedSnapshotDescription` and `assistantInl…
cstns May 20, 2026
762c12d
Enforce AI feature dependency checks for feature flags and assistant-…
cstns May 20, 2026
ca86a10
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
hardillb May 21, 2026
93900f4
Enable AI features by default in configuration
cstns May 21, 2026
2f1391d
Merge branch 'generic-ai-platform-and-team-level-feature-flag' into g…
cstns May 21, 2026
c59bd41
Merge branch 'refs/heads/gate-snapshot-description-and-inline-complet…
cstns May 21, 2026
9cc3e9b
Refactor `TeamTypeEditDialog` to filter features dynamically based on…
cstns May 21, 2026
951ca7a
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
cstns May 21, 2026
59a81a7
Merge branch 'generic-ai-platform-and-team-level-feature-flag' into g…
cstns May 21, 2026
c1df27a
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
cstns May 21, 2026
f4ad728
Merge branch 'generic-ai-platform-and-team-level-feature-flag' into g…
cstns May 21, 2026
e01f4db
Merge branch 'gate-snapshot-description-and-inline-completion-behind-…
cstns May 21, 2026
da44fa5
Enable AI features for teams by default
cstns May 21, 2026
fd527a8
Extract feature flag logic to a reusable composable and update AI fea…
cstns May 21, 2026
d052383
Add team-level opt-out for `generatedSnapshotDescription` feature flag
cstns May 21, 2026
b3d66f6
Default AI features to true in `TeamTypeEditDialog` if undefined
cstns May 21, 2026
838abc9
Ensure `isAiEnabled` is explicitly cast to boolean across feature fla…
cstns May 21, 2026
f1278c1
Add tests for AI feature flag handling at platform and team levels
cstns May 21, 2026
62d5e70
Migrate `FeatureChecks.js` to TypeScript as `FeatureChecks.ts` for im…
cstns May 21, 2026
7690ae2
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
hardillb May 26, 2026
9e57794
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
hardillb May 26, 2026
a19595b
Merge branch 'main' into generic-ai-platform-and-team-level-feature-flag
cstns Jun 2, 2026
25ac21d
Merge branch 'generic-ai-platform-and-team-level-feature-flag' into g…
cstns Jun 2, 2026
c02d849
Merge branch 'gate-snapshot-description-and-inline-completion-behind-…
cstns Jun 2, 2026
ea3abdb
Merge branch 'refs/heads/main' into gate-all-ai-features-begind-ai-pl…
cstns Jun 2, 2026
225bff9
Merge branch 'main' into gate-all-ai-features-begind-ai-platform-and-…
cstns Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions forge/ee/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,21 @@ module.exports = fp(async function (app, opts) {
await app.register(require('./expert'))

// Set the AI Features Flag (global gate for all AI features)
app.config.features.register('ai', app.config?.ai?.enabled ?? true, true)
const isAiEnabled = app.config?.ai?.enabled ?? true
app.config.features.register('ai', isAiEnabled, true)

// Set the Generate Snapshot Description Feature Flag
const isAssistantConfigured = app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url
const isAssistantConfigured = isAiEnabled && app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url
app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true)

// Set the assistant inline completions Feature Flag
app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true)

// Set the expert assistant Feature Flag
app.config.features.register('expertAssistant', app.config?.expert?.enabled ?? false, true)
app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true)

// temporary until FF Expert Insights can be enabled on Self Hosted EE instance
const isInsightsEnabled = app.config?.expert?.enabled && app.config?.expert?.insights?.enabled
const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled
app.config.features.register('expertInsights', isInsightsEnabled ?? false, false)
}

Expand Down
3 changes: 2 additions & 1 deletion forge/routes/api/assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,10 @@ module.exports = async function (app) {
async (request, reply) => {
const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false
const featureEnabled = app.config.features.enabled('assistantInlineCompletions')
const isAiEnabled = !!(app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true))
const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false)
const isStandaloneSessionUser = request.session.ownerType === 'user'
if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || featureEnabledForTeam)) {
if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabled && featureEnabledForTeam))) {
reply.code(404).send({ code: 'not_found', error: 'Not Found - feature not enabled for team' })
return
}
Expand Down
3 changes: 2 additions & 1 deletion forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -1190,9 +1190,10 @@ module.exports = async function (app) {
await request.device.Team.ensureTeamTypeExists()
const tier = app.license.get('tier')
const isEnterprise = tier === 'enterprise'
const isAiEnabled = !!(app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true))
const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false)

if (!isEnterprise || !hasFeature) {
if (!isEnterprise || !isAiEnabled || !hasFeature) {
return reply.code(404).send({ code: 'not_found' })
}
}
Expand Down
5 changes: 3 additions & 2 deletions forge/routes/api/deviceLive.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,10 @@ module.exports = async function (app) {
tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true))
}

const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true))
const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
response.assistant = {
enabled: app.config.assistant?.enabled || false,
enabled: isAiEnabled && (app.config.assistant?.enabled || false),
requestTimeout: app.config.assistant?.requestTimeout || 60000,
mcp: { enabled: true }, // default to enabled
completions: {
Expand Down
8 changes: 5 additions & 3 deletions forge/routes/api/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -849,9 +849,10 @@ module.exports = async function (app) {

await request.project.Team.ensureTeamTypeExists()
const team = request.project.Team
const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true))
const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
settings.assistant = {
enabled: app.config.assistant?.enabled || false,
enabled: isAiEnabled && (app.config.assistant?.enabled || false),
requestTimeout: app.config.assistant?.requestTimeout || 60000,
mcp: { enabled: true }, // default to enabled
completions: {
Expand Down Expand Up @@ -1463,9 +1464,10 @@ module.exports = async function (app) {
await request.project.Team.ensureTeamTypeExists()
const tier = app.license.get('tier')
const isEnterprise = tier === 'enterprise'
const isAiEnabled = !!(app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true))
const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false)

if (!isEnterprise || !hasFeature) {
if (!isEnterprise || !isAiEnabled || !hasFeature) {
return reply.code(404).send({ code: 'not_found' })
}
}
Expand Down
192 changes: 192 additions & 0 deletions frontend/src/composables/FeatureChecks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Feature flag configuration array. Each entry defines how a feature's availability
* is computed from platform settings and team type properties.
*
* For each entry, `buildFeatureChecks` produces up to three computed properties:
* - `{output}ForPlatform` - platform-level check result (only if `platformKey` is set)
* - `{output}ForTeam` - team-level check result (only if `teamKey` is set)
* - `{output}` - combined result (see combination rules below)
*
* @property {string} output - Name of the computed property for the combined result.
* Always required. Convention: `is{Feature}FeatureEnabled`.
*
* @property {string} [platformKey] - Key to look up in the platform features object
* (state.features). If truthy, the platform check passes.
*
* @property {string} [teamKey] - Key to look up in team type properties
* (team.type.properties.features). If truthy or if the team type has
* `enableAllFeatures: true`, the team check passes.
*
* @property {boolean} [optOut=false] - Controls the default behavior of the team check.
* - `false` (default, opt-in): feature is disabled unless the team type explicitly
* enables it or has `enableAllFeatures: true`.
* - `true` (opt-out): feature is enabled by default. Only disabled if the team type
* explicitly sets it to `false`. If the property is `undefined`, it is treated as enabled.
* Only meaningful when `teamKey` is set. Ignored otherwise.
*
* @property {string} [platformSource] - Override where the platform value is read from.
* - `undefined` (default): reads from `state.features` (the platform feature flags)
* - `'settings'`: reads from `state.settings.features` instead (for features exposed
* through the settings object rather than the feature flags system)
* Only meaningful when `platformKey` is set. Ignored otherwise.
*
* @property {string} [dependsOn] - The `output` name of another feature that must be
* enabled (combined check) for this feature to be enabled. If the referenced feature's
* combined result is `false`, this feature is forced to `false`.
* The dependency must appear earlier in the array to ensure it is computed first.
*
* @property {string} [dependsOnPlatform] - A platform feature key that must be enabled
* for this feature to be enabled. Checked directly against the platform features object,
* independent of whether the dependency has its own entry in FEATURE_CONFIGS.
*
* @property {string} [dependsOnPlatformSource] - Override where the `dependsOnPlatform`
* value is read from, same semantics as `platformSource`.
* Only meaningful when `dependsOnPlatform` is set. Ignored otherwise.
*
* @property {string} [dependsOnTeam] - A team feature key that must be enabled for this
* feature to be enabled. Checked directly against the team type properties.
*
* @property {boolean} [dependsOnTeamOptOut] - Controls the default behavior of the
* `dependsOnTeam` check, same semantics as `optOut`.
* Only meaningful when `dependsOnTeam` is set. Ignored otherwise.
*
* Combination rules for the `{output}` property:
* - If both `platformKey` and `teamKey` are set: `platform AND team`
* - If only `platformKey` is set: `platform` only (platform-only feature)
* - If only `teamKey` is set: `team` only (team-only feature)
* - After the base combination, all `dependsOn*` gates are applied. If any
* dependency check fails, the combined result is forced to `false`.
*
* At least one of `platformKey` or `teamKey` must be provided.
* `dependsOn`, `dependsOnPlatform`, and `dependsOnTeam` can be used together.
*/

interface FeatureConfig {
output: string
platformKey?: string
teamKey?: string
optOut?: boolean
platformSource?: 'settings'
dependsOn?: string
dependsOnPlatform?: string
dependsOnPlatformSource?: 'settings'
dependsOnTeam?: string
dependsOnTeamOptOut?: boolean
}

interface PlatformState {
features?: Record<string, boolean>
settings?: {
features?: Record<string, boolean>
}
}

interface TeamTypeProperties {
features?: Record<string, boolean>
enableAllFeatures?: boolean
}

interface Team {
type?: {
properties?: TeamTypeProperties
}
}

type FeatureChecks = Record<string, boolean>

export const FEATURE_CONFIGS: FeatureConfig[] = [
{ output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true },
{ output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true },
{ output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true },
{ output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' },
{ output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' },
{ output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' },
{ output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' },
{ output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' },
{ output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' },
{ output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' },
{ output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' },
{ output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' },
{ output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' },
{
output: 'isGeneratedSnapshotDescriptionFeatureEnabled',
platformKey: 'generatedSnapshotDescription',
teamKey: 'generatedSnapshotDescription',
dependsOnPlatform: 'ai',
dependsOnTeam: 'ai',
dependsOnTeamOptOut: true
},
{ output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' },

// Team-only
{ output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' },

// Platform-only
{ output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' },
{ output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' },
{ output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', dependsOnPlatform: 'ai' },
{ output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' },
{ output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' },
{ output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' }
]

function isPlatformFeatureEnabled (state: PlatformState, platformKey: string, platformSource?: 'settings'): boolean {
const source = platformSource === 'settings' ? state.settings?.features : state.features
return !!source?.[platformKey]
}

function isTeamFeatureEnabled (team: Team | null | undefined, teamKey: string, optOut?: boolean): boolean {
if (optOut) {
const flag = team?.type?.properties?.features?.[teamKey]
return (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures
}
return !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures
}

function applyDependencyGates (
checks: FeatureChecks,
output: string,
config: FeatureConfig,
state: PlatformState,
team: Team | null | undefined
): void {
if (config.dependsOn && !checks[config.dependsOn]) {
checks[output] = false
}
if (config.dependsOnPlatform && !isPlatformFeatureEnabled(state, config.dependsOnPlatform, config.dependsOnPlatformSource)) {
checks[output] = false
}
if (config.dependsOnTeam && !isTeamFeatureEnabled(team, config.dependsOnTeam, config.dependsOnTeamOptOut)) {
checks[output] = false
}
}

export function buildFeatureChecks (state: PlatformState, team: Team | null | undefined): FeatureChecks {
const checks: FeatureChecks = {}

for (const config of FEATURE_CONFIGS) {
const { output, platformKey, teamKey, optOut, platformSource } = config
const platformCheckKey = `${output}ForPlatform`
const teamCheckKey = `${output}ForTeam`

if (platformKey) {
checks[platformCheckKey] = isPlatformFeatureEnabled(state, platformKey, platformSource)
}

if (teamKey) {
checks[teamCheckKey] = isTeamFeatureEnabled(team, teamKey, optOut)
}

if (platformKey && teamKey) {
checks[output] = checks[platformCheckKey] && checks[teamCheckKey]
} else if (platformKey) {
checks[output] = checks[platformCheckKey]
} else if (teamKey) {
checks[output] = checks[teamCheckKey]
}

applyDependencyGates(checks, output, config, state, team)
}

return checks
}
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,9 @@ export default {
if (this.input.properties.features.instanceResources === undefined) {
this.input.properties.features.instanceResources = false
}
if (this.input.properties.features.ai === undefined) {
this.input.properties.features.ai = true
}
if (!this.input.autoStack) {
this.input.autoStack = {}
}
Expand Down
63 changes: 1 addition & 62 deletions frontend/src/stores/account-settings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'

import settingsApi from '@/api/settings.js'
import { buildFeatureChecks } from '@/composables/FeatureChecks'
import { useAccountAuthStore } from '@/stores/account-auth.js'
import { useContextStore } from '@/stores/context.js'

Expand All @@ -9,68 +10,6 @@ export const POSTHOG_FLAGS = {
EXPERT_COMMS_BETA_ENABLED: 'EXPERT_COMMS_BETA_ENABLED'
}

const FEATURE_CONFIGS = [
{ output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true },
{ output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true },
{ output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true },
{ output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' },
{ output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' },
{ output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' },
{ output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' },
{ output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' },
{ output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' },
{ output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' },
{ output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' },
{ output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' },
{ output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' },
{ output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription' },
{ output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' },

// Team-only
{ output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' },

// Platform-only
{ output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' },
{ output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' },
{ output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant' },
{ output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' },
{ output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' },
{ output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' }
]

function buildFeatureChecks (state, team) {
const checks = {}

for (const { output, platformKey, teamKey, optOut, platformSource } of FEATURE_CONFIGS) {
const platformCheckKey = `${output}ForPlatform`
const teamCheckKey = `${output}ForTeam`

if (platformKey) {
const source = platformSource === 'settings' ? state.settings?.features : state.features
checks[platformCheckKey] = !!source?.[platformKey]
}

if (teamKey) {
if (optOut) {
const flag = team?.type?.properties?.features?.[teamKey]
checks[teamCheckKey] = (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures
} else {
checks[teamCheckKey] = !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures
}
}

if (platformKey && teamKey) {
checks[output] = checks[platformCheckKey] && checks[teamCheckKey]
} else if (platformKey) {
checks[output] = checks[platformCheckKey]
} else if (teamKey) {
checks[output] = checks[teamCheckKey]
}
}

return checks
}

export const useAccountSettingsStore = defineStore('account-settings', {
state: () => ({
settings: null,
Expand Down
Loading
Loading