Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions forge/routes/api/assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,41 @@ module.exports = async function (app) {
}
}
})
/**
* Endpoint for nr-assistant to check, live, whether the team has opted in to
* agent-initiated deploys. Deliberately not cached/pushed via settings.js - it
* must be checked at the moment a deploy is being considered so that turning the
* team setting off takes effect immediately, without an instance restart.
* @name /api/v1/assistant/deploy-policy
* @static
* @memberof forge.routes.api.assistant
*/
app.get('/deploy-policy', {
config: {
rateLimit: app.config.rate_limits
? {
hook: 'preHandler', // apply the rate as a preHandler so that session is available
max: 60, // max requests per window
timeWindow: 60000, // 1 minute window
keyGenerator: (request) => {
return request.ownerId || request.ip
}
}
: false
},
schema: {
hide: true // dont show in swagger

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why hide it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for now, we can come back on it later if somebody wants to query that

}
}, async (request, reply) => {
const isAiEnabled = !!(app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true))
// agentAutoDeploy authorises unattended deploys, so it must be an explicit team-level
// opt-in - read the team's own override directly rather than through
// Team.getFeatureProperty, which falls back to TeamType.getFeatureProperty and would
// return true for every team on any TeamType bootstrapped with enableAllFeatures (the
// platform default), regardless of whether anyone actually opted in.
const autoDeploy = isAiEnabled && request.team?.properties?.features?.agentAutoDeploy === true
reply.send({ autoDeploy })
})
/**
* Endpoint for FIM (fill-in-the-middle) code completion requests
* For now, this is simply a relay to an external assistant service
Expand Down
2 changes: 1 addition & 1 deletion forge/routes/api/team.js
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,7 @@ module.exports = async function (app) {
}
} else if (Object.hasOwn(request.body, 'features')) {
// Team owners can update feature overrides (e.g. opt out of AI)
const allowedFeatures = ['ai']
const allowedFeatures = ['ai', 'agentAutoDeploy']
const requestedFeatures = request.body.features || {}
const currentProperties = typeof request.team.properties === 'string' ? JSON.parse(request.team.properties) : (request.team.properties || {})
currentProperties.features = currentProperties.features || {}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/composables/TeamProperties.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ function getProperty (properties, key) {
}

export function getTeamProperty (team, property, defaultValue) {
const teamValue = getProperty(team.properties, property)
const teamValue = getProperty(team?.properties, property)
if (teamValue === undefined) {
// No value found in team properties. Check the TeamType properties
return getProperty(team.type.properties, property) ?? defaultValue
return getProperty(team?.type?.properties, property) ?? defaultValue
}
return teamValue
}
50 changes: 48 additions & 2 deletions frontend/src/pages/team/Settings/Danger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@
<ff-toggle-switch v-model="aiEnabled" data-el="team-ai-toggle" @change="showConfirmAiToggleDialog" />
</div>
</div>
<FormHeading>AI Flow Deploy</FormHeading>
<div class="flex flex-col space-y-4 max-w-2xl lg:flex-row lg:items-center lg:space-y-0">
<div class="grow">
<div class="max-w-sm pr-2">Allow AI agents to deploy flow changes they make on this team's instances, without waiting for a person to click Deploy.</div>
<div v-if="!aiEnabled" class="max-w-sm pr-2 text-gray-400 italic">Enable AI Features above to use this.</div>
</div>
<div class="min-w-fit shrink-0">
<ff-toggle-switch v-model="agentAutoDeploy" :disabled="!aiEnabled" data-el="team-agent-auto-deploy-toggle" @change="showConfirmAgentAutoDeployToggleDialog" />
</div>
</div>
</template>
<TeamAdminTools v-if="isAdmin" :team="team" />
</div>
Expand All @@ -68,6 +78,7 @@ import teamApi from '../../../api/team.js'
import teamTypesApi from '../../../api/teamTypes.js'

import FormHeading from '../../../components/FormHeading.vue'
import { getTeamProperty } from '../../../composables/TeamProperties.js'

import alerts from '../../../services/alerts.js'
import Dialog from '../../../services/dialog.js'
Expand All @@ -92,7 +103,8 @@ export default {
data () {
return {
teamTypes: [],
aiEnabledOverride: null
aiEnabledOverride: null,
agentAutoDeployOverride: null
}
},
computed: {
Expand All @@ -107,11 +119,22 @@ export default {
if (this.aiEnabledOverride !== null) {
return this.aiEnabledOverride
}
return this.team?.type?.properties?.features?.ai !== false
return getTeamProperty(this.team, 'features.ai', true) !== false
},
set (value) {
this.aiEnabledOverride = value
}
},
agentAutoDeploy: {
get () {
if (this.agentAutoDeployOverride !== null) {
return this.agentAutoDeployOverride
}
return !!getTeamProperty(this.team, 'features.agentAutoDeploy', false)
},
set (value) {
this.agentAutoDeployOverride = value
}
}
},
async created () {
Expand Down Expand Up @@ -175,6 +198,29 @@ export default {
}, () => {
this.aiEnabledOverride = null
})
},
showConfirmAgentAutoDeployToggleDialog () {
const enabling = this.agentAutoDeploy
Dialog.show({
header: enabling ? 'Enable AI Flow Deploy' : 'Disable AI Flow Deploy',
kind: enabling ? 'danger' : 'primary',
text: enabling
? 'Are you sure you want to allow AI agents to deploy flows on this team\'s instances?'
: 'Are you sure you want to disable AI agents deploying flow changes automatically?',
confirmLabel: enabling ? 'Enable' : 'Disable'
}, () => {
teamApi.updateTeam(this.team.id, { features: { agentAutoDeploy: enabling } }).then(() => {
alerts.emit(`AI Flow Deploy ${enabling ? 'enabled' : 'disabled'}`, 'confirmation')
this.agentAutoDeployOverride = null
useContextStore().refreshTeam()
}).catch(err => {
alerts.emit('Problem updating AI Flow Deploy settings', 'warning')
this.agentAutoDeployOverride = null
console.warn(err)
})
}, () => {
this.agentAutoDeployOverride = null
})
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/stores/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export const useContextStore = defineStore('context', {
}
return getTeamProperty(this.team, 'trial.runtimesLimit') ?? null
},
// Mirrors Team.getFeatureProperty on the backend (team override falling back to the
// TeamType/plan default), gated on both the platform `ai` feature and the team's own `ai`
// opt-out - matching what the live check `/api/v1/assistant/deploy-policy` actually gates
// on (`isAiEnabled = platform 'ai' && team.getFeatureProperty('ai', true)`).
agentAutoDeployEnabled () {
if (!this.team) {
return false
}
const platformAiEnabled = useAccountSettingsStore().featuresCheck?.isAiFeatureEnabledForPlatform
const teamAiEnabled = getTeamProperty(this.team, 'features.ai', true)
const agentAutoDeploy = getTeamProperty(this.team, 'features.agentAutoDeploy', false)
return !!(platformAiEnabled && teamAiEnabled && agentAutoDeploy)
},
editorEntityType (state) {
const name = state.route?.name
if (name?.startsWith('instance-editor')) return 'instance'
Expand Down Expand Up @@ -98,6 +111,7 @@ export const useContextStore = defineStore('context', {
teamId: this.team?.id || null,
teamSlug: this.team?.slug || null,
telemetryEnabled: useAccountSettingsStore().featuresCheck?.isTelemetryEnabled ?? false,
agentAutoDeployEnabled: this.agentAutoDeployEnabled,
instanceId: null,
deviceId: null,
applicationId: null,
Expand Down Expand Up @@ -142,6 +156,7 @@ export const useContextStore = defineStore('context', {
teamId: this.team?.id || null,
teamSlug: this.team?.slug || null,
telemetryEnabled: useAccountSettingsStore().featuresCheck?.isTelemetryEnabled ?? false,
agentAutoDeployEnabled: this.agentAutoDeployEnabled,
instanceId: state.instance ? state.instance.id : null,
deviceId: state.device ? state.device.id : null,
applicationId: this.application ? this.application.id : null,
Expand Down
85 changes: 85 additions & 0 deletions test/unit/forge/routes/api/assistant_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,91 @@ describe('Assistant API', async function () {
})
})

describe('deploy-policy endpoint', async function () {
// agentAutoDeploy authorises unattended deploys, so the route reads the team's own
// properties.features.agentAutoDeploy directly - it must never inherit "true" through
// Team.getFeatureProperty's TeamType fallback, which would make it default-on for
// every team on a TeamType bootstrapped with enableAllFeatures (the platform default -
// see forge/db/controllers/TeamType.js). These tests don't need to touch the TeamType
// at all as a result; the one below that does is there specifically to prove that.
let originalTeamProperties
beforeEach(async function () {
originalTeamProperties = TestObjects.ATeam.properties
})
afterEach(async function () {
TestObjects.ATeam.properties = originalTeamProperties
await TestObjects.ATeam.save()
})
it('reports autoDeploy false by default', async function () {
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/deploy-policy',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }
})
response.statusCode.should.equal(200)
response.json().should.have.property('autoDeploy', false)
})
it('reports autoDeploy false when the TeamType has enableAllFeatures: true and the team has never opted in', async function () {
const defaultTeamType = await app.db.models.TeamType.findOne({ where: { name: 'starter' } })
const originalTeamTypeProperties = JSON.parse(JSON.stringify(defaultTeamType.properties))
try {
const props = defaultTeamType.properties
props.enableAllFeatures = true
defaultTeamType.properties = props
await defaultTeamType.save()

const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/deploy-policy',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }
})
response.statusCode.should.equal(200)
response.json().should.have.property('autoDeploy', false)
} finally {
defaultTeamType.properties = originalTeamTypeProperties
await defaultTeamType.save()
}
})
it('reports autoDeploy true when the team has enabled it and ai is enabled', async function () {
TestObjects.ATeam.properties = { features: { agentAutoDeploy: true } }
await TestObjects.ATeam.save()
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/deploy-policy',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }
})
response.statusCode.should.equal(200)
response.json().should.have.property('autoDeploy', true)
})
it('reports autoDeploy false when agentAutoDeploy is enabled but the team has opted out of ai', async function () {
TestObjects.ATeam.properties = { features: { agentAutoDeploy: true, ai: false } }
await TestObjects.ATeam.save()
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/deploy-policy',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }
})
response.statusCode.should.equal(200)
response.json().should.have.property('autoDeploy', false)
})
it('reports autoDeploy false when the platform ai feature is disabled', async function () {
TestObjects.ATeam.properties = { features: { agentAutoDeploy: true } }
await TestObjects.ATeam.save()
app.config.features.register('ai', false, true)
try {
const response = await app.inject({
method: 'GET',
url: '/api/v1/assistant/deploy-policy',
headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }
})
response.statusCode.should.equal(200)
response.json().should.have.property('autoDeploy', false)
} finally {
app.config.features.register('ai', true, true)
}
})
})

describe('assets endpoint', async function () {
const assetUrl1 = '/api/v1/assistant/assets/model.json'
const assetUrl2 = '/api/v1/assistant/assets/model.bin'
Expand Down
56 changes: 56 additions & 0 deletions test/unit/forge/routes/api/team_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,62 @@ describe('Team API', function () {
team.should.have.property('TeamTypeId', newTeamType.id)
})

describe('Feature overrides (features branch)', async function () {
// PUT /api/v1/teams/:teamId { features: {...} }
it('owner can toggle agentAutoDeploy', async function () {
const team = await app.db.models.Team.create({ name: 'update-team-feat-1', slug: 'team-feat-1', TeamTypeId: app.defaultTeamType.id })
await team.addUser(TestObjects.bob, { through: { role: Roles.Owner } })

const response = await app.inject({
method: 'PUT',
url: `/api/v1/teams/${team.hashid}`,
payload: {
features: { agentAutoDeploy: true }
},
cookies: { sid: TestObjects.tokens.bob }
})
response.statusCode.should.equal(200)

await team.reload()
team.properties.features.should.have.property('agentAutoDeploy', true)
})
it('member cannot toggle agentAutoDeploy', async function () {
const team = await app.db.models.Team.create({ name: 'update-team-feat-2', slug: 'team-feat-2', TeamTypeId: app.defaultTeamType.id })
await team.addUser(TestObjects.bob, { through: { role: Roles.Member } })

const response = await app.inject({
method: 'PUT',
url: `/api/v1/teams/${team.hashid}`,
payload: {
features: { agentAutoDeploy: true }
},
cookies: { sid: TestObjects.tokens.bob }
})
response.statusCode.should.equal(403)

await team.reload()
should.not.exist(team.properties?.features?.agentAutoDeploy)
})
it('ignores feature keys outside the allowlist', async function () {
const team = await app.db.models.Team.create({ name: 'update-team-feat-3', slug: 'team-feat-3', TeamTypeId: app.defaultTeamType.id })
await team.addUser(TestObjects.bob, { through: { role: Roles.Owner } })

const response = await app.inject({
method: 'PUT',
url: `/api/v1/teams/${team.hashid}`,
payload: {
features: { agentAutoDeploy: true, notAllowed: true }
},
cookies: { sid: TestObjects.tokens.bob }
})
response.statusCode.should.equal(200)

await team.reload()
team.properties.features.should.have.property('agentAutoDeploy', true)
should.not.exist(team.properties.features.notAllowed)
})
})

describe('Suspending team', async function () {
it('non-owner cannot suspend team', async function () {
const teamName = generateName('suspend-team')
Expand Down
35 changes: 35 additions & 0 deletions test/unit/frontend/composables/TeamProperties.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'vitest'

import { getTeamProperty } from '../../../../frontend/src/composables/TeamProperties.js'

describe('getTeamProperty', () => {
test('returns the team-level override when present', () => {
const team = { properties: { features: { agentAutoDeploy: true } }, type: { properties: { features: { agentAutoDeploy: false } } } }
expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(true)
})

test('falls back to the TeamType property when the team has no override', () => {
const team = { properties: {}, type: { properties: { features: { agentAutoDeploy: true } } } }
expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(true)
})

test('falls back to the default value when neither has the property', () => {
const team = { properties: {}, type: { properties: { features: {} } } }
expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(false)
})

test('does not throw when the team has no type', () => {
const team = { id: 'team-1', slug: 'my-team' }
expect(getTeamProperty(team, 'features.agentAutoDeploy', false)).toBe(false)
})

test('does not throw when team is null or undefined', () => {
expect(getTeamProperty(null, 'features.agentAutoDeploy', false)).toBe(false)
expect(getTeamProperty(undefined, 'features.agentAutoDeploy', false)).toBe(false)
})

test('resolves a nested dotted path', () => {
const team = { properties: {}, type: { properties: { trial: { runtimesLimit: 3 } } } }
expect(getTeamProperty(team, 'trial.runtimesLimit')).toBe(3)
})
})
Loading
Loading