diff --git a/docs/commands/agents.md b/docs/commands/agents.md index fed5c606d8c..b6898dced1a 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -33,7 +33,9 @@ netlify agents | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent run’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent run to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | | [`agents:sync`](/commands/agents#agentssync) | Bring an agent run up to date with the latest code from its base branch | @@ -256,6 +258,39 @@ netlify agents:pr netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a ``` +--- +## `agents:publish` + +Publish an agent run’s changes to production + +**Usage** + +```bash +netlify agents:publish +``` + +**Arguments** + +- id - agent run ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `force` (*boolean*) - publish even when the run is out of sync with production +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes +netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force +``` + --- ## `agents:redeploy` @@ -287,6 +322,37 @@ netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8... ``` +--- +## `agents:revert` + +Revert an agent run to a specific session (sessions after it are discarded) + +**Usage** + +```bash +netlify agents:revert +``` + +**Arguments** + +- id - agent run ID + +**Flags** + +- `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `json` (*boolean*) - output result as JSON +- `project` (*string*) - project ID or name (if not in a linked directory) +- `session` (*string*) - session ID to revert to +- `yes` (*boolean*) - skip confirmation prompt +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:revert 60c7c3b3e7b4a0001f5e4b3a --session 70d8... +``` + --- ## `agents:show` diff --git a/docs/index.md b/docs/index.md index a4756cb9a87..fa7094a7e5c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,7 +30,9 @@ Manage Netlify AI agent runs | [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site | | [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser | | [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run | +| [`agents:publish`](/commands/agents#agentspublish) | Publish an agent run’s changes to production | | [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | +| [`agents:revert`](/commands/agents#agentsrevert) | Revert an agent run to a specific session (sessions after it are discarded) | | [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run | | [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run | | [`agents:sync`](/commands/agents#agentssync) | Bring an agent run up to date with the latest code from its base branch | diff --git a/src/commands/agents/agents-publish.ts b/src/commands/agents/agents-publish.ts new file mode 100644 index 00000000000..c384ab79c2b --- /dev/null +++ b/src/commands/agents/agents-publish.ts @@ -0,0 +1,126 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' +import type { AgentRunner } from './types.js' +import { buildAgentDashboardUrl } from './utils.js' + +interface AgentPublishOptions extends OptionValues { + json?: boolean + yes?: boolean + force?: boolean +} + +const isOutOfSync = (runner: AgentRunner): boolean => + Boolean(runner.needs_git_sync || runner.rebase_available || runner.merge_target_available) + +const describeOutOfSync = (runner: AgentRunner): string => { + if (runner.needs_git_sync) return 'the code origin has changed since this run started' + if (runner.merge_target_available) return 'the target branch has new commits' + return 'production has moved on since this run started' +} + +export const agentsPublish = async (id: string, options: AgentPublishOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent run ID is required') + await command.authenticate() + const { siteInfo } = command.netlify + const api = createAgentsApi(command.netlify) + + const lookupSpinner = startSpinner({ text: 'Checking agent run state...' }) + let runner: AgentRunner + try { + runner = await api.getAgentRunner(id) + stopSpinner({ spinner: lookupSpinner }) + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent run not found: ${id}`) + return logAndThrowError(`Failed to fetch agent run: ${error.message}`) + } + + const outOfSync = isOutOfSync(runner) + if (outOfSync && !options.force) { + if (options.json) { + return logAndThrowError( + `Refusing to publish: ${describeOutOfSync(runner)}. Run netlify agents:sync ${id} first, or pass --force.`, + ) + } + log(chalk.yellow(`! This agent run is out of date: ${describeOutOfSync(runner)}.`)) + log(` Sync first: ${chalk.cyan(`netlify agents:sync ${id}`)}`) + log(` Or override: pass ${chalk.cyan('--force')} to publish the existing diff as-is`) + if (!options.yes) { + if (!process.stdin.isTTY) return logAndThrowError('Refusing to publish out-of-date run without --force') + const { action } = await inquirer.prompt<{ action: 'sync' | 'publish' | 'cancel' }>([ + { + type: 'list', + name: 'action', + message: 'How would you like to proceed?', + choices: [ + { + name: 'Sync with production now, then re-run publish manually (recommended)', + value: 'sync', + }, + { name: 'Publish anyway (use the current diff as-is)', value: 'publish' }, + { name: 'Cancel', value: 'cancel' }, + ], + default: 'sync', + }, + ]) + if (action === 'cancel') return exit() + if (action === 'sync') { + const { agentsSync } = await import('./agents-sync.js') + await agentsSync(id, { yes: true }, command) + log() + log(chalk.dim(`After the sync completes, re-run: ${chalk.cyan(`netlify agents:publish ${id}`)}`)) + return + } + // action === 'publish' falls through to the publish call below + } else { + return logAndThrowError('Refusing to publish out-of-date run without --force') + } + } + + if (!options.yes && !options.json && !outOfSync) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to publish without --yes when stdin is not a TTY') + } + log(chalk.redBright('Warning'), 'You are about to publish agent changes to production.') + log(` Site: ${chalk.bold(siteInfo.name)}`) + log(` Run: ${chalk.bold(id)}`) + log() + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Publish agent run ${id} to production?`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Publishing to production...' }) + try { + const updated = await api.agentRunnerPublishToProduction(id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(updated) + return updated + } + + log(`${chalk.green('✓')} Published agent run to production!`) + log() + log(` Run ID: ${chalk.cyan(updated.id)}`) + if (updated.merge_commit_sha) log(` Commit: ${chalk.cyan(updated.merge_commit_sha)}`) + log(` Browser: ${chalk.blue(buildAgentDashboardUrl(siteInfo.name, updated.id))}`) + return updated + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to publish: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-revert.ts b/src/commands/agents/agents-revert.ts new file mode 100644 index 00000000000..8463085a1fb --- /dev/null +++ b/src/commands/agents/agents-revert.ts @@ -0,0 +1,56 @@ +import type { OptionValues } from 'commander' +import inquirer from 'inquirer' + +import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js' +import { startSpinner, stopSpinner } from '../../lib/spinner.js' +import type BaseCommand from '../base-command.js' +import { createAgentsApi } from './api.js' + +interface AgentRevertOptions extends OptionValues { + json?: boolean + yes?: boolean + session?: string +} + +export const agentsRevert = async (id: string, options: AgentRevertOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent run ID is required') + if (!options.session) return logAndThrowError('--session is required: revert targets a specific session') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + if (!options.yes && !options.json) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to revert without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Revert agent run ${id} to session ${options.session}? Sessions after that will be discarded.`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Reverting agent run...' }) + try { + const runner = await api.revertAgentRunner(id, options.session) + stopSpinner({ spinner }) + + if (options.json) { + logJson(runner) + return runner + } + + log(`${chalk.green('✓')} Agent run reverted!`) + log(` Run ID: ${chalk.cyan(runner.id)}`) + log(` Reverted to session: ${chalk.cyan(options.session)}`) + return runner + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error & { status?: number } + if (error.status === 404) return logAndThrowError(`Agent run or session not found: ${id} / ${options.session}`) + return logAndThrowError(`Failed to revert: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index c17a832c65c..1cd534bca39 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -167,6 +167,40 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsCommit(id, options, command) }) + program + .command('agents:publish') + .argument('', 'agent run ID') + .description('Publish an agent run’s changes to production') + .option('-y, --yes', 'skip confirmation prompt') + .option('--force', 'publish even when the run is out of sync with production') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --yes', + 'netlify agents:publish 60c7c3b3e7b4a0001f5e4b3a --force', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsPublish } = await import('./agents-publish.js') + await agentsPublish(id, options, command) + }) + + program + .command('agents:revert') + .argument('', 'agent run ID') + .description('Revert an agent run to a specific session (sessions after it are discarded)') + .requiredOption('--session ', 'session ID to revert to') + .option('-y, --yes', 'skip confirmation prompt') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples(['netlify agents:revert 60c7c3b3e7b4a0001f5e4b3a --session 70d8...']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsRevert } = await import('./agents-revert.js') + await agentsRevert(id, options, command) + }) + program .command('agents:redeploy') .argument('', 'agent run ID') diff --git a/tests/integration/commands/agents/agents-publish.test.ts b/tests/integration/commands/agents/agents-publish.test.ts new file mode 100644 index 00000000000..83d8e1e3fec --- /dev/null +++ b/tests/integration/commands/agents/agents-publish.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:publish command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + const runnerInSync = { + ...mockAgentRunner, + rebase_available: false, + merge_target_available: false, + needs_git_sync: false, + } + + test('should publish to production with --yes', async (t) => { + const runnerWithCommit = { ...runnerInSync, merge_commit_sha: 'def5678' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: runnerWithCommit, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Published agent run to production!') + expect(cliResponse).toContain('Run ID: agent_runner_id') + expect(cliResponse).toContain('Commit: def5678') + }) + }) + }) + + test('should refuse to publish without --yes when stdin is not a TTY', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:publish', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'Refusing to publish without --yes when stdin is not a TTY', + ) + }) + }) + }) + + test('should publish without --yes if --json is set (treats it as non-interactive)', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: runnerInSync, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof runnerInSync + + expect(cliResponse).toEqual(runnerInSync) + }) + }) + }) + + test('should refuse to publish an out-of-date run without --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:publish', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Refusing to publish out-of-date run without --force') + }) + }) + }) + + test('should publish an out-of-date run with --force', async (t) => { + const staleRunner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: staleRunner, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + response: { ...staleRunner, merge_commit_sha: 'abc' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:publish', 'test_id', '--force', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Published agent run to production!') + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runnerInSync, + }, + { + path: 'agent_runners/test_id/publish_to_production', + method: 'POST' as const, + status: 500, + response: { error: 'kaboom' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:publish', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to publish: kaboom') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect(callCli(['agents:publish'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-revert.test.ts b/tests/integration/commands/agents/agents-revert.test.ts new file mode 100644 index 00000000000..2ffe736b4c4 --- /dev/null +++ b/tests/integration/commands/agents/agents-revert.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { callCli } from '../../utils/call-cli.js' +import { getCLIOptions, withMockApi } from '../../utils/mock-api.js' +import { withSiteBuilder } from '../../utils/site-builder.js' +import { mockSiteInfo, mockAgentRunner } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:revert command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const baseRoutes = [ + { path: 'sites/site_id', response: mockSiteInfo }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'user', response: { name: 'test user' } }, + { path: 'accounts', response: [{ slug: 'test-account' }] }, + ] + + test('should revert to a session with --yes', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:revert', 'test_id', '--session', 'session_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Agent run reverted!') + expect(cliResponse).toContain('Reverted to session: session_id') + + const revertRequest = requests.find((r) => r.path.endsWith('/revert') && r.method === 'POST') + expect(revertRequest?.body).toEqual({ session_id: 'session_id' }) + }) + }) + }) + + test('should refuse to revert without --yes when stdin is not a TTY', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--session', 'session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Refusing to revert without --yes when stdin is not a TTY') + }) + }) + }) + + test('should require --session', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow(/required option.*--session/) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:revert', 'test_id', '--session', 'session_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentRunner + + expect(cliResponse).toEqual(mockAgentRunner) + }) + }) + }) + + test('should handle API errors', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/revert', + method: 'POST' as const, + status: 500, + response: { error: 'broken' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', 'test_id', '--session', 'session_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to revert: broken') + }) + }) + }) + + test('should require agent ID argument', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(baseRoutes, async ({ apiUrl }) => { + await expect( + callCli(['agents:revert', '--session', 'session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('missing required argument') + }) + }) + }) +})