From 6c74d2eae9c4748fe52f673a8b5186e6e2156512 Mon Sep 17 00:00:00 2001 From: Vaibhav Acharya Date: Mon, 1 Jun 2026 15:28:52 +0530 Subject: [PATCH] feat(agents): add sync and redeploy commands - agents:sync realigns a run with production, auto-picking rebase, merge_target, or sync_git_origin based on runner state; no-ops when already up to date - agents:redeploy re-runs the deploy for a run Part 5/8 of the agents CLI revamp split. --- docs/commands/agents.md | 64 +++++++ docs/index.md | 2 + src/commands/agents/agents-redeploy.ts | 70 ++++++++ src/commands/agents/agents-sync.ts | 105 +++++++++++ src/commands/agents/agents.ts | 31 ++++ .../commands/agents/agents-redeploy.test.ts | 165 +++++++++++++++++ .../commands/agents/agents-sync.test.ts | 167 ++++++++++++++++++ 7 files changed, 604 insertions(+) create mode 100644 src/commands/agents/agents-redeploy.ts create mode 100644 src/commands/agents/agents-sync.ts create mode 100644 tests/integration/commands/agents/agents-redeploy.test.ts create mode 100644 tests/integration/commands/agents/agents-sync.test.ts diff --git a/docs/commands/agents.md b/docs/commands/agents.md index d6a96f699ae..fed5c606d8c 100644 --- a/docs/commands/agents.md +++ b/docs/commands/agents.md @@ -33,8 +33,10 @@ 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:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | | [`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 | **Examples** @@ -254,6 +256,37 @@ netlify agents:pr netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a ``` +--- +## `agents:redeploy` + +Redeploy an agent run by reapplying its existing changes (no AI inference) + +**Usage** + +```bash +netlify agents:redeploy +``` + +**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*) - redeploy a specific session (defaults to the latest completed one) +- `debug` (*boolean*) - Print debugging information +- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in + +**Examples** + +```bash +netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a +netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8... +``` + --- ## `agents:show` @@ -318,6 +351,37 @@ netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --yes ``` +--- +## `agents:sync` + +Bring an agent run up to date with the latest code from its base branch + +**Usage** + +```bash +netlify agents:sync +``` + +**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) +- `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:sync 60c7c3b3e7b4a0001f5e4b3a +netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes +``` + --- diff --git a/docs/index.md b/docs/index.md index 4015cbad0dd..a4756cb9a87 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,8 +30,10 @@ 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:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) | | [`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 | ### [api](/commands/api) diff --git a/src/commands/agents/agents-redeploy.ts b/src/commands/agents/agents-redeploy.ts new file mode 100644 index 00000000000..c4a79fd5f2e --- /dev/null +++ b/src/commands/agents/agents-redeploy.ts @@ -0,0 +1,70 @@ +import type { OptionValues } from 'commander' + +import { chalk, 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 { formatStatus } from './utils.js' + +interface AgentRedeployOptions extends OptionValues { + session?: string + json?: boolean +} + +export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent run ID is required') + await command.authenticate() + const api = createAgentsApi(command.netlify) + + let sessionId = options.session + if (!sessionId) { + const lookupSpinner = startSpinner({ text: 'Finding latest completed session...' }) + try { + const perPage = 100 + const maxPages = 10 + let page = 1 + let latestDone: { id: string } | undefined + while (!latestDone && page <= maxPages) { + const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage, order_by: 'desc' }) + latestDone = sessions.find((session) => session.state === 'done') + if (latestDone || sessions.length < perPage) break + page += 1 + } + stopSpinner({ spinner: lookupSpinner }) + if (!latestDone) { + return logAndThrowError('No completed session found to redeploy. Pass --session to target a specific one.') + } + sessionId = latestDone.id + } catch (error_) { + stopSpinner({ spinner: lookupSpinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to list sessions: ${error.message}`) + } + } + + const spinner = startSpinner({ text: 'Creating redeploy session...' }) + try { + const session = await api.redeployAgentRunnerSession(id, sessionId) + stopSpinner({ spinner }) + + if (options.json) { + logJson(session) + return session + } + + log(`${chalk.green('✓')} Redeploy session created!`) + log() + log(` Run ID: ${chalk.cyan(id)}`) + log(` Session ID: ${chalk.cyan(session.id)}`) + log(` Source Session: ${chalk.dim(sessionId)}`) + log(` Status: ${formatStatus(session.state)}`) + log() + log(`Watch progress: ${chalk.cyan(`netlify agents:show ${id} --watch`)}`) + return session + } 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} / ${sessionId}`) + return logAndThrowError(`Failed to redeploy: ${error.message}`) + } +} diff --git a/src/commands/agents/agents-sync.ts b/src/commands/agents/agents-sync.ts new file mode 100644 index 00000000000..c233824bf0d --- /dev/null +++ b/src/commands/agents/agents-sync.ts @@ -0,0 +1,105 @@ +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, type AgentsApi } from './api.js' +import type { AgentRunner } from './types.js' + +interface AgentSyncOptions extends OptionValues { + json?: boolean + yes?: boolean +} + +type SyncStrategy = 'sync_git_origin' | 'merge_target' | 'rebase' + +const pickStrategy = (runner: AgentRunner): SyncStrategy | null => { + if (runner.needs_git_sync) return 'sync_git_origin' + if (runner.merge_target_available) return 'merge_target' + if (runner.rebase_available) return 'rebase' + return null +} + +const describeStrategy = (strategy: SyncStrategy, runner: AgentRunner): string => { + const target = runner.branch ? ` (target: ${runner.branch})` : '' + switch (strategy) { + case 'sync_git_origin': + return `sync with the remote git origin${target}` + case 'merge_target': + return `merge the latest target branch into this agent run${target}` + case 'rebase': + return 'reapply changes on top of the latest production deploy' + } +} + +const runStrategy = (api: AgentsApi, strategy: SyncStrategy, id: string): Promise => { + switch (strategy) { + case 'sync_git_origin': + return api.syncGitOriginAgentRunner(id) + case 'merge_target': + return api.mergeTargetAgentRunner(id) + case 'rebase': + return api.rebaseAgentRunner(id) + } +} + +export const agentsSync = async (id: string, options: AgentSyncOptions, command: BaseCommand) => { + if (!id) return logAndThrowError('Agent run ID is required') + await command.authenticate() + 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 strategy = pickStrategy(runner) + if (!strategy) { + log(chalk.yellow('Nothing to sync — this agent run is already up to date.')) + return runner + } + + if (!options.yes && !options.json) { + if (!process.stdin.isTTY) { + return logAndThrowError('Refusing to sync without --yes when stdin is not a TTY') + } + const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([ + { + type: 'confirm', + name: 'confirmed', + message: `Sync agent run ${id}? This will ${describeStrategy(strategy, runner)}.`, + default: false, + }, + ]) + if (!confirmed) return exit() + } + + const spinner = startSpinner({ text: 'Syncing agent run...' }) + try { + const updated = await runStrategy(api, strategy, id) + stopSpinner({ spinner }) + + if (options.json) { + logJson(updated) + return updated + } + + log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy, runner)}.`) + log(` Run ID: ${chalk.cyan(updated.id)}`) + log() + log(`Watch progress: ${chalk.cyan(`netlify agents:show ${updated.id} --watch`)}`) + return updated + } catch (error_) { + stopSpinner({ spinner, error: true }) + const error = error_ as Error + return logAndThrowError(`Failed to sync: ${error.message}`) + } +} diff --git a/src/commands/agents/agents.ts b/src/commands/agents/agents.ts index 89aa3c0e293..c17a832c65c 100644 --- a/src/commands/agents/agents.ts +++ b/src/commands/agents/agents.ts @@ -167,6 +167,37 @@ export const createAgentsCommand = (program: BaseCommand) => { await agentsCommit(id, options, command) }) + program + .command('agents:redeploy') + .argument('', 'agent run ID') + .description('Redeploy an agent run by reapplying its existing changes (no AI inference)') + .option('--session ', 'redeploy a specific session (defaults to the latest completed one)') + .option('--json', 'output result as JSON') + .option('--project ', 'project ID or name (if not in a linked directory)') + .hook('preAction', requiresSiteInfoWithProject) + .addExamples([ + 'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a', + 'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8...', + ]) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsRedeploy } = await import('./agents-redeploy.js') + await agentsRedeploy(id, options, command) + }) + + program + .command('agents:sync') + .argument('', 'agent run ID') + .description('Bring an agent run up to date with the latest code from its base branch') + .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:sync 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes']) + .action(async (id: string, options: OptionValues, command: BaseCommand) => { + const { agentsSync } = await import('./agents-sync.js') + await agentsSync(id, options, command) + }) + const name = chalk.greenBright('`agents`') return program diff --git a/tests/integration/commands/agents/agents-redeploy.test.ts b/tests/integration/commands/agents/agents-redeploy.test.ts new file mode 100644 index 00000000000..a2ae7a27333 --- /dev/null +++ b/tests/integration/commands/agents/agents-redeploy.test.ts @@ -0,0 +1,165 @@ +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, mockAgentSession } from './fixtures.js' + +vi.mock('../../../../src/lib/spinner.js', () => ({ + startSpinner: vi.fn(() => ({ text: 'test' })), + stopSpinner: vi.fn(), +})) + +describe('agents:redeploy 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 redeploy a specific session', async (t) => { + const newSession = { ...mockAgentSession, id: 'new_session_id', state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id', '--session', 'old_session_id'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Redeploy session created!') + expect(cliResponse).toContain('Session ID: new_session_id') + expect(cliResponse).toContain('Source Session: old_session_id') + }) + }) + }) + + test('should pick the latest done session when no --session is given', async (t) => { + const completedSession = { ...mockAgentSession, id: 'done_session_id', state: 'done' } + const newSession = { ...mockAgentSession, id: 'new_session_id', state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [completedSession], + }, + { + path: 'agent_runners/test_id/sessions/done_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Redeploy session created!') + expect(cliResponse).toContain('Source Session: done_session_id') + }) + }) + }) + + test('should error when no completed session exists', async (t) => { + const runningSession = { ...mockAgentSession, state: 'running' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions', + method: 'GET' as const, + response: [runningSession], + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect(callCli(['agents:redeploy', 'test_id'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'No completed session found to redeploy', + ) + }) + }) + }) + + test('should return JSON when --json flag is used', async (t) => { + const newSession = { ...mockAgentSession, id: 'new_session_id' } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + response: newSession, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:redeploy', 'test_id', '--session', 'old_session_id', '--json'], + getCLIOptions({ apiUrl, builder }), + true, + )) as typeof mockAgentSession + + expect(cliResponse).toEqual(newSession) + }) + }) + }) + + test('should handle API errors when redeploying', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id/sessions/old_session_id/redeploy', + method: 'POST' as const, + status: 500, + response: { error: 'oops' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:redeploy', 'test_id', '--session', 'old_session_id'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Failed to redeploy: oops') + }) + }) + }) + + 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:redeploy'], getCLIOptions({ apiUrl, builder }))).rejects.toThrow( + 'missing required argument', + ) + }) + }) + }) +}) diff --git a/tests/integration/commands/agents/agents-sync.test.ts b/tests/integration/commands/agents/agents-sync.test.ts new file mode 100644 index 00000000000..fa1363a0e4c --- /dev/null +++ b/tests/integration/commands/agents/agents-sync.test.ts @@ -0,0 +1,167 @@ +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:sync 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 rebase when only rebase is available', async (t) => { + const runner = { ...mockAgentRunner, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/rebase', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Sync started') + expect(cliResponse).toContain('reapply changes on top of the latest production deploy') + const syncRequest = requests.find((r) => r.path.endsWith('/rebase')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should merge target when merge_target is available', async (t) => { + const runner = { ...mockAgentRunner, merge_target_available: true, rebase_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/merge_target', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('merge the latest target branch') + const mergeRequest = requests.find((r) => r.path.endsWith('/merge_target')) + expect(mergeRequest).toBeDefined() + }) + }) + }) + + test('should sync git origin when needs_git_sync is set', async (t) => { + const runner = { ...mockAgentRunner, needs_git_sync: true, rebase_available: true, merge_target_available: true } + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: runner, + }, + { + path: 'agent_runners/test_id/sync_git_origin', + method: 'POST' as const, + response: runner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl, requests }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('sync with the remote git origin') + const syncRequest = requests.find((r) => r.path.endsWith('/sync_git_origin')) + expect(syncRequest).toBeDefined() + }) + }) + }) + + test('should report when nothing needs syncing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/test_id', + method: 'GET' as const, + response: mockAgentRunner, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + const cliResponse = (await callCli( + ['agents:sync', 'test_id', '--yes'], + getCLIOptions({ apiUrl, builder }), + )) as string + + expect(cliResponse).toContain('Nothing to sync') + }) + }) + }) + + test('should surface 404 when the task is missing', async (t) => { + const routes = [ + ...baseRoutes, + { + path: 'agent_runners/missing_id', + method: 'GET' as const, + status: 404, + response: { error: 'Not found' }, + }, + ] + + await withSiteBuilder(t, async (builder) => { + await builder.build() + + await withMockApi(routes, async ({ apiUrl }) => { + await expect( + callCli(['agents:sync', 'missing_id', '--yes'], getCLIOptions({ apiUrl, builder })), + ).rejects.toThrow('Agent run not found: missing_id') + }) + }) + }) +})