Skip to content

Commit ea0ef56

Browse files
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 splitting #8237.
1 parent e8fd223 commit ea0ef56

7 files changed

Lines changed: 604 additions & 0 deletions

File tree

docs/commands/agents.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ netlify agents
3333
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
3434
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
3535
| [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run |
36+
| [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) |
3637
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
3738
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |
39+
| [`agents:sync`](/commands/agents#agentssync) | Bring an agent run up to date with the latest code from its base branch |
3840

3941

4042
**Examples**
@@ -254,6 +256,37 @@ netlify agents:pr
254256
netlify agents:pr 60c7c3b3e7b4a0001f5e4b3a
255257
```
256258

259+
---
260+
## `agents:redeploy`
261+
262+
Redeploy an agent run by reapplying its existing changes (no AI inference)
263+
264+
**Usage**
265+
266+
```bash
267+
netlify agents:redeploy
268+
```
269+
270+
**Arguments**
271+
272+
- id - agent run ID
273+
274+
**Flags**
275+
276+
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
277+
- `json` (*boolean*) - output result as JSON
278+
- `project` (*string*) - project ID or name (if not in a linked directory)
279+
- `session` (*string*) - redeploy a specific session (defaults to the latest completed one)
280+
- `debug` (*boolean*) - Print debugging information
281+
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
282+
283+
**Examples**
284+
285+
```bash
286+
netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a
287+
netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8...
288+
```
289+
257290
---
258291
## `agents:show`
259292

@@ -318,6 +351,37 @@ netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a
318351
netlify agents:stop 60c7c3b3e7b4a0001f5e4b3a --yes
319352
```
320353

354+
---
355+
## `agents:sync`
356+
357+
Bring an agent run up to date with the latest code from its base branch
358+
359+
**Usage**
360+
361+
```bash
362+
netlify agents:sync
363+
```
364+
365+
**Arguments**
366+
367+
- id - agent run ID
368+
369+
**Flags**
370+
371+
- `filter` (*string*) - For monorepos, specify the name of the application to run the command in
372+
- `json` (*boolean*) - output result as JSON
373+
- `project` (*string*) - project ID or name (if not in a linked directory)
374+
- `yes` (*boolean*) - skip confirmation prompt
375+
- `debug` (*boolean*) - Print debugging information
376+
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in
377+
378+
**Examples**
379+
380+
```bash
381+
netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a
382+
netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes
383+
```
384+
321385
---
322386

323387
<!-- AUTO-GENERATED-CONTENT:END -->

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ Manage Netlify AI agent runs
3030
| [`agents:list`](/commands/agents#agentslist) | List agent runs for the current site |
3131
| [`agents:open`](/commands/agents#agentsopen) | Open the agent run preview, dashboard, or pull request in a browser |
3232
| [`agents:pr`](/commands/agents#agentspr) | Open a pull request for an agent run |
33+
| [`agents:redeploy`](/commands/agents#agentsredeploy) | Redeploy an agent run by reapplying its existing changes (no AI inference) |
3334
| [`agents:show`](/commands/agents#agentsshow) | Show details of a specific agent run |
3435
| [`agents:stop`](/commands/agents#agentsstop) | Stop a running agent run |
36+
| [`agents:sync`](/commands/agents#agentssync) | Bring an agent run up to date with the latest code from its base branch |
3537

3638

3739
### [api](/commands/api)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { OptionValues } from 'commander'
2+
3+
import { chalk, log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
4+
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
5+
import type BaseCommand from '../base-command.js'
6+
import { createAgentsApi } from './api.js'
7+
import { formatStatus } from './utils.js'
8+
9+
interface AgentRedeployOptions extends OptionValues {
10+
session?: string
11+
json?: boolean
12+
}
13+
14+
export const agentsRedeploy = async (id: string, options: AgentRedeployOptions, command: BaseCommand) => {
15+
if (!id) return logAndThrowError('Agent run ID is required')
16+
await command.authenticate()
17+
const api = createAgentsApi(command.netlify)
18+
19+
let sessionId = options.session
20+
if (!sessionId) {
21+
const lookupSpinner = startSpinner({ text: 'Finding latest completed session...' })
22+
try {
23+
const perPage = 100
24+
const maxPages = 10
25+
let page = 1
26+
let latestDone: { id: string } | undefined
27+
while (!latestDone && page <= maxPages) {
28+
const sessions = await api.listAgentRunnerSessions(id, { page, per_page: perPage, order_by: 'desc' })
29+
latestDone = sessions.find((session) => session.state === 'done')
30+
if (latestDone || sessions.length < perPage) break
31+
page += 1
32+
}
33+
stopSpinner({ spinner: lookupSpinner })
34+
if (!latestDone) {
35+
return logAndThrowError('No completed session found to redeploy. Pass --session <id> to target a specific one.')
36+
}
37+
sessionId = latestDone.id
38+
} catch (error_) {
39+
stopSpinner({ spinner: lookupSpinner, error: true })
40+
const error = error_ as Error
41+
return logAndThrowError(`Failed to list sessions: ${error.message}`)
42+
}
43+
}
44+
45+
const spinner = startSpinner({ text: 'Creating redeploy session...' })
46+
try {
47+
const session = await api.redeployAgentRunnerSession(id, sessionId)
48+
stopSpinner({ spinner })
49+
50+
if (options.json) {
51+
logJson(session)
52+
return session
53+
}
54+
55+
log(`${chalk.green('✓')} Redeploy session created!`)
56+
log()
57+
log(` Run ID: ${chalk.cyan(id)}`)
58+
log(` Session ID: ${chalk.cyan(session.id)}`)
59+
log(` Source Session: ${chalk.dim(sessionId)}`)
60+
log(` Status: ${formatStatus(session.state)}`)
61+
log()
62+
log(`Watch progress: ${chalk.cyan(`netlify agents:show ${id} --watch`)}`)
63+
return session
64+
} catch (error_) {
65+
stopSpinner({ spinner, error: true })
66+
const error = error_ as Error & { status?: number }
67+
if (error.status === 404) return logAndThrowError(`Agent run or session not found: ${id} / ${sessionId}`)
68+
return logAndThrowError(`Failed to redeploy: ${error.message}`)
69+
}
70+
}

src/commands/agents/agents-sync.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { OptionValues } from 'commander'
2+
import inquirer from 'inquirer'
3+
4+
import { chalk, exit, log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
5+
import { startSpinner, stopSpinner } from '../../lib/spinner.js'
6+
import type BaseCommand from '../base-command.js'
7+
import { createAgentsApi, type AgentsApi } from './api.js'
8+
import type { AgentRunner } from './types.js'
9+
10+
interface AgentSyncOptions extends OptionValues {
11+
json?: boolean
12+
yes?: boolean
13+
}
14+
15+
type SyncStrategy = 'sync_git_origin' | 'merge_target' | 'rebase'
16+
17+
const pickStrategy = (runner: AgentRunner): SyncStrategy | null => {
18+
if (runner.needs_git_sync) return 'sync_git_origin'
19+
if (runner.merge_target_available) return 'merge_target'
20+
if (runner.rebase_available) return 'rebase'
21+
return null
22+
}
23+
24+
const describeStrategy = (strategy: SyncStrategy, runner: AgentRunner): string => {
25+
const target = runner.branch ? ` (target: ${runner.branch})` : ''
26+
switch (strategy) {
27+
case 'sync_git_origin':
28+
return `sync with the remote git origin${target}`
29+
case 'merge_target':
30+
return `merge the latest target branch into this agent run${target}`
31+
case 'rebase':
32+
return 'reapply changes on top of the latest production deploy'
33+
}
34+
}
35+
36+
const runStrategy = (api: AgentsApi, strategy: SyncStrategy, id: string): Promise<AgentRunner> => {
37+
switch (strategy) {
38+
case 'sync_git_origin':
39+
return api.syncGitOriginAgentRunner(id)
40+
case 'merge_target':
41+
return api.mergeTargetAgentRunner(id)
42+
case 'rebase':
43+
return api.rebaseAgentRunner(id)
44+
}
45+
}
46+
47+
export const agentsSync = async (id: string, options: AgentSyncOptions, command: BaseCommand) => {
48+
if (!id) return logAndThrowError('Agent run ID is required')
49+
await command.authenticate()
50+
const api = createAgentsApi(command.netlify)
51+
52+
const lookupSpinner = startSpinner({ text: 'Checking agent run state...' })
53+
let runner: AgentRunner
54+
try {
55+
runner = await api.getAgentRunner(id)
56+
stopSpinner({ spinner: lookupSpinner })
57+
} catch (error_) {
58+
stopSpinner({ spinner: lookupSpinner, error: true })
59+
const error = error_ as Error & { status?: number }
60+
if (error.status === 404) return logAndThrowError(`Agent run not found: ${id}`)
61+
return logAndThrowError(`Failed to fetch agent run: ${error.message}`)
62+
}
63+
64+
const strategy = pickStrategy(runner)
65+
if (!strategy) {
66+
log(chalk.yellow('Nothing to sync — this agent run is already up to date.'))
67+
return runner
68+
}
69+
70+
if (!options.yes && !options.json) {
71+
if (!process.stdin.isTTY) {
72+
return logAndThrowError('Refusing to sync without --yes when stdin is not a TTY')
73+
}
74+
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
75+
{
76+
type: 'confirm',
77+
name: 'confirmed',
78+
message: `Sync agent run ${id}? This will ${describeStrategy(strategy, runner)}.`,
79+
default: false,
80+
},
81+
])
82+
if (!confirmed) return exit()
83+
}
84+
85+
const spinner = startSpinner({ text: 'Syncing agent run...' })
86+
try {
87+
const updated = await runStrategy(api, strategy, id)
88+
stopSpinner({ spinner })
89+
90+
if (options.json) {
91+
logJson(updated)
92+
return updated
93+
}
94+
95+
log(`${chalk.green('✓')} Sync started: ${describeStrategy(strategy, runner)}.`)
96+
log(` Run ID: ${chalk.cyan(updated.id)}`)
97+
log()
98+
log(`Watch progress: ${chalk.cyan(`netlify agents:show ${updated.id} --watch`)}`)
99+
return updated
100+
} catch (error_) {
101+
stopSpinner({ spinner, error: true })
102+
const error = error_ as Error
103+
return logAndThrowError(`Failed to sync: ${error.message}`)
104+
}
105+
}

src/commands/agents/agents.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,37 @@ export const createAgentsCommand = (program: BaseCommand) => {
167167
await agentsCommit(id, options, command)
168168
})
169169

170+
program
171+
.command('agents:redeploy')
172+
.argument('<id>', 'agent run ID')
173+
.description('Redeploy an agent run by reapplying its existing changes (no AI inference)')
174+
.option('--session <sid>', 'redeploy a specific session (defaults to the latest completed one)')
175+
.option('--json', 'output result as JSON')
176+
.option('--project <project>', 'project ID or name (if not in a linked directory)')
177+
.hook('preAction', requiresSiteInfoWithProject)
178+
.addExamples([
179+
'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a',
180+
'netlify agents:redeploy 60c7c3b3e7b4a0001f5e4b3a --session 70d8...',
181+
])
182+
.action(async (id: string, options: OptionValues, command: BaseCommand) => {
183+
const { agentsRedeploy } = await import('./agents-redeploy.js')
184+
await agentsRedeploy(id, options, command)
185+
})
186+
187+
program
188+
.command('agents:sync')
189+
.argument('<id>', 'agent run ID')
190+
.description('Bring an agent run up to date with the latest code from its base branch')
191+
.option('-y, --yes', 'skip confirmation prompt')
192+
.option('--json', 'output result as JSON')
193+
.option('--project <project>', 'project ID or name (if not in a linked directory)')
194+
.hook('preAction', requiresSiteInfoWithProject)
195+
.addExamples(['netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a', 'netlify agents:sync 60c7c3b3e7b4a0001f5e4b3a --yes'])
196+
.action(async (id: string, options: OptionValues, command: BaseCommand) => {
197+
const { agentsSync } = await import('./agents-sync.js')
198+
await agentsSync(id, options, command)
199+
})
200+
170201
const name = chalk.greenBright('`agents`')
171202

172203
return program

0 commit comments

Comments
 (0)