Merge branch 'feature/JAL-3954-ebi-phmmer' into mmw/JAL-4199-task-execution-update mmw/JAL-4199-task-execution-update
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Fri, 14 Jul 2023 14:01:32 +0000 (16:01 +0200)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Fri, 14 Jul 2023 14:01:32 +0000 (16:01 +0200)
1  2 
src/jalview/ws2/actions/BaseJob.java
src/jalview/ws2/actions/BaseTask.java
src/jalview/ws2/actions/NullAction.java
src/jalview/ws2/actions/NullTask.java
src/jalview/ws2/actions/api/TaskEventListener.java
src/jalview/ws2/actions/hmmer/PhmmerAction.java
src/jalview/ws2/actions/hmmer/PhmmerTask.java
src/jalview/ws2/client/ebi/JobDispatcherWSDiscoverer.java
src/jalview/ws2/gui/SearchServiceGuiHandler.java
src/jalview/ws2/gui/WebServicesMenuManager.java

Simple merge
index 803df3c,0000000..387ca69
mode 100644,000000..100644
--- /dev/null
@@@ -1,310 -1,0 +1,311 @@@
 +package jalview.ws2.actions;
 +
 +import java.io.IOException;
 +import java.util.ArrayList;
 +import java.util.Collections;
 +import java.util.List;
 +import java.util.Objects;
 +
 +import jalview.bin.Console;
 +import jalview.util.ArrayUtils;
 +import jalview.util.MathUtils;
 +import jalview.ws.params.ArgumentI;
 +import jalview.ws2.actions.api.TaskEventListener;
 +import jalview.ws2.actions.api.TaskI;
 +import jalview.ws2.api.Credentials;
 +import jalview.ws2.api.JobStatus;
 +import jalview.ws2.client.api.WebServiceClientI;
 +import jalview.ws2.helpers.DelegateJobEventListener;
 +import jalview.ws2.helpers.TaskEventSupport;
 +
 +import static java.lang.String.format;
 +
 +public abstract class BaseTask<T extends BaseJob, R> implements TaskI<R>
 +{
 +  protected final long uid = MathUtils.getUID();
 +
 +  protected final WebServiceClientI webClient;
 +
 +  protected final List<ArgumentI> args;
 +
 +  protected final Credentials credentials;
 +
 +  private final TaskEventSupport<R> eventHandler;
 +
 +  protected JobStatus status = JobStatus.CREATED;
 +
 +  protected List<T> jobs = Collections.emptyList();
 +
 +  protected R result = null;
 +
 +  protected Runnable cancelAction = () -> {
 +  };
 +
 +  protected BaseTask(WebServiceClientI webClient, List<ArgumentI> args,
 +      Credentials credentials)
 +  {
 +    this.webClient = webClient;
 +    this.args = args;
 +    this.credentials = credentials;
 +    this.eventHandler = new TaskEventSupport<>(this);
 +  }
 +
 +  @Override
 +  public final long getUid()
 +  {
 +    return uid;
 +  }
 +
 +  @Override
 +  public final JobStatus getStatus()
 +  {
 +    return status;
 +  }
 +
 +  @Override
 +  public final List<? extends BaseJob> getSubJobs()
 +  {
 +    return jobs;
 +  }
 +
 +  @Override
 +  public final void addTaskEventListener(TaskEventListener<R> listener)
 +  {
 +    eventHandler.addListener(listener);
 +  }
 +
 +  @Override
 +  public final void removeTaskEventListener(TaskEventListener<R> listener)
 +  {
 +    eventHandler.addListener(listener);
 +  }
 +
 +  @Override
 +  public final R getResult()
 +  {
 +    return result;
 +  }
 +
 +  @Override
 +  public final void init() throws Exception
 +  {
 +    try
 +    {
 +      jobs = prepareJobs();
 +    } catch (ServiceInputInvalidException e)
 +    {
 +      setStatus(JobStatus.INVALID);
 +      eventHandler.fireTaskException(e);
 +      throw e;
 +    }
 +    setStatus(JobStatus.READY);
 +    eventHandler.fireTaskStarted(jobs);
 +    var jobListener = new DelegateJobEventListener<>(eventHandler);
 +    for (var job : jobs)
 +      job.addPropertyChangeListener(jobListener);
 +    submitJobs(jobs);
 +  }
 +
 +  static final int MAX_SUBMIT_RETRY = 5;
 +
 +  protected final void submitJobs(List<T> jobs) throws IOException
 +  {
 +    var retryCounter = 0;
 +    while (true)
 +    {
 +      try
 +      {
 +        submitJobs0(jobs);
 +        setStatus(JobStatus.SUBMITTED);
 +        break;
 +      } catch (IOException e)
 +      {
 +        eventHandler.fireTaskException(e);
 +        if (++retryCounter > MAX_SUBMIT_RETRY)
 +        {
 +          cancel();
 +          setStatus(JobStatus.SERVER_ERROR);
 +          throw e;
 +        }
 +      }
 +    }
 +  }
 +
 +  private final void submitJobs0(List<T> jobs) throws IOException
 +  {
 +    IOException exception = null;
 +    for (BaseJob job : jobs)
 +    {
 +      if (job.getStatus() != JobStatus.READY || !job.isInputValid())
 +        continue;
 +      try
 +      {
 +        var jobRef = webClient.submit(job.getInputSequences(), args, credentials);
 +        job.setServerJob(jobRef);
 +        job.setStatus(JobStatus.SUBMITTED);
 +      } catch (IOException e)
 +      {
 +        exception = e;
 +      }
 +    }
 +    if (exception != null)
 +      throw exception;
 +  }
 +
 +  /**
 +   * Poll all running jobs and update their status and logs. Polling is repeated
 +   * periodically until this method return true when all jobs are done.
 +   * 
 +   * @return {@code true] if all jobs are done @throws IOException if server
 +   *         error occurred
 +   */
 +  @Override
 +  public final boolean poll() throws IOException
 +  {
 +    boolean allDone = true;
 +    IOException exception = null;
 +    for (BaseJob job : jobs)
 +    {
 +      if (job.isInputValid() && !job.getStatus().isDone())
 +      {
 +        var serverJob = job.getServerJob();
 +        try
 +        {
 +          job.setStatus(webClient.getStatus(serverJob));
 +          job.setLog(webClient.getLog(serverJob));
 +          job.setErrorLog(webClient.getErrorLog(serverJob));
 +        } catch (IOException e)
 +        {
 +          exception = e;
 +        }
 +      }
 +      allDone &= job.isCompleted();
 +    }
 +    updateGlobalStatus();
 +    if (exception != null)
 +      throw exception;
 +    return allDone;
 +  }
 +
 +  @Override
 +  public final void complete() throws IOException
 +  {
 +    for (var job : jobs)
 +    {
 +      if (!job.isCompleted())
 +      {
 +        // a fallback in case the executor decides to finish prematurely
 +        cancelJob(job);
 +        job.setStatus(JobStatus.SERVER_ERROR);
 +      }
 +    }
 +    updateGlobalStatus();
 +    try {
 +      result = collectResult(jobs);
 +      eventHandler.fireTaskCompleted(result);
 +    }
 +    catch (Exception e)
 +    {
 +      eventHandler.fireTaskException(e);
 +      throw e;
 +    }
 +  }
 +
 +  /**
 +   * Cancel all running jobs. Used in case of task failure to cleanup the
 +   * resources or when the task has been cancelled.
 +   */
 +  @Override
 +  public final void cancel()
 +  {
 +    cancelAction.run();
 +    for (T job : jobs)
 +    {
 +      cancelJob(job);
 +    }
++    setStatus(JobStatus.CANCELLED);
 +  }
 +
 +  private final void cancelJob(T job)
 +  {
 +    if (!job.isCompleted())
 +    {
 +      try
 +      {
 +        if (job.getServerJob() != null)
 +          webClient.cancel(job.getServerJob());
 +        job.setStatus(JobStatus.CANCELLED);
 +      } catch (IOException e)
 +      {
 +        Console.error(format("failed to cancel job %s", job.getServerJob()), e);
 +      }
 +    }
 +  }
 +
 +  protected final void setStatus(JobStatus status)
 +  {
 +    Objects.requireNonNull(status);
 +    if (this.status != status)
 +    {
 +      this.status = status;
 +      eventHandler.fireTaskStatusChanged(status);
 +    }
 +  }
 +
 +  protected abstract List<T> prepareJobs() throws ServiceInputInvalidException;
 +
 +  protected abstract R collectResult(List<T> jobs) throws IOException;
 +
 +  /**
 +   * Update task status according to the overall status of its jobs. The rules
 +   * of setting the status are following:
 +   * <ul>
 +   * <li>task is invalid if all jobs are invalid</li>
 +   * <li>task is completed if all but invalid jobs are completed</li>
 +   * <li>task is ready, submitted or queued if at least one job is ready,
 +   * submitted or queued an none proceeded to the next stage excluding
 +   * completed.</li>
 +   * <li>task is running if at least one job is running and none are failed or
 +   * cancelled</li>
 +   * <li>task is cancelled if at least one job is cancelled and none failed</li>
 +   * <li>task is failed or server error if at least one job is failed or server
 +   * error</li>
 +   * </ul>
 +   */
 +  protected final void updateGlobalStatus()
 +  {
 +    int precedence = -1;
 +    for (BaseJob job : jobs)
 +    {
 +      JobStatus status = job.getStatus();
 +      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
 +      if (precedence < jobPrecedence)
 +        precedence = jobPrecedence;
 +    }
 +    if (precedence >= 0)
 +    {
 +      setStatus(JobStatus.statusPrecedence[precedence]);
 +    }
 +  }
 +
 +  /**
 +   * Set the action that will be run when the {@link #cancel()} method is
 +   * invoked. The action should typically stop the executor polling the task and
 +   * release resources and threads running the task.
 +   * 
 +   * @param action
 +   *          runnable to be executed when the task is cancelled
 +   */
 +  public void setCancelAction(Runnable action)
 +  {
 +    Objects.requireNonNull(action);
 +    this.cancelAction = action;
 +  }
 +
 +  @Override
 +  public String toString()
 +  {
 +    var statusName = status != null ? status.name() : "UNSET";
 +    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, statusName);
 +  }
 +}
index 0000000,655a133..f91cef6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,52 +1,51 @@@
+ package jalview.ws2.actions;
+ import java.util.List;
++import jalview.api.AlignViewportI;
+ import jalview.viewmodel.AlignmentViewport;
+ import jalview.ws.params.ArgumentI;
+ import jalview.ws2.actions.api.ActionI;
 -import jalview.ws2.actions.api.TaskEventListener;
+ import jalview.ws2.actions.api.TaskI;
+ import jalview.ws2.api.Credentials;
+ /**
+  * An empty implementation of the {@link ActionI} interface that does nothing.
+  * Use as a placeholder for testing purposes.
+  * 
+  * @author mmwarowny
+  *
+  */
+ public final class NullAction extends BaseAction<Void>
+ {
+   public static final class Builder extends BaseAction.Builder<NullAction>
+   {
+     public NullAction build()
+     {
+       return new NullAction(this);
+     }
+   }
+   
+   public static Builder newBuilder()
+   {
+     return new Builder();
+   }
+   
+   protected NullAction(Builder builder)
+   {
+     super(builder);
+   }
+   @Override
 -  public TaskI<Void> perform(AlignmentViewport viewport,
 -          List<ArgumentI> args, Credentials credentials,
 -          TaskEventListener<Void> handler)
++  public TaskI<Void> createTask(AlignViewportI viewport,
++          List<ArgumentI> args, Credentials credentials)
+   {
+     return new NullTask();
+   }
+   @Override
+   public boolean isActive(AlignmentViewport viewport)
+   {
+     return false;
+   }
+ }
index 0000000,223b9fb..5dd5ab0
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,47 +1,74 @@@
+ package jalview.ws2.actions;
+ import java.util.Collections;
+ import java.util.List;
+ import jalview.ws2.actions.api.JobI;
++import jalview.ws2.actions.api.TaskEventListener;
+ import jalview.ws2.actions.api.TaskI;
+ import jalview.ws2.api.JobStatus;
+ /**
 - * An empty task returned by the {@link NullAction}. Use as a placeholder
 - * for testing purposes.
++ * An empty task returned by the {@link NullAction}. Use as a placeholder for
++ * testing purposes.
+  * 
+  * @author mmwarowny
+  *
+  */
+ class NullTask implements TaskI<Void>
+ {
+   @Override
+   public long getUid()
+   {
+     return 0;
+   }
+   @Override
+   public JobStatus getStatus()
+   {
+     return JobStatus.READY;
+   }
+   @Override
+   public List<? extends JobI> getSubJobs()
+   {
+     return Collections.emptyList();
+   }
+   @Override
++  public void init() throws Exception
++  {
++  }
++
++  @Override
++  public boolean poll() throws Exception
++  {
++    return true;
++  }
++
++  @Override
++  public void complete() throws Exception
++  {
++  }
++
++  @Override
+   public Void getResult()
+   {
+     return null;
+   }
+   @Override
+   public void cancel()
+   {
+   }
++
++  @Override
++  public void addTaskEventListener(TaskEventListener<Void> listener)
++  {
++  }
++
++  @Override
++  public void removeTaskEventListener(TaskEventListener<Void> listener)
++  {
++  }
+ }
@@@ -97,5 -108,62 +98,56 @@@ public interface TaskEventListener<T
     * @param log
     *          new log string
     */
 -  void subJobErrorLogChanged(TaskI<T> source, JobI job, String log);
 +  default void subJobErrorLogChanged(TaskI<T> source, JobI job, String log) {};
+   @SuppressWarnings("rawtypes")
+   static final TaskEventListener NULL_LISTENER = new TaskEventListener()
+   {
+     @Override
+     public void taskStarted(TaskI source, List subJobs)
+     {
+       Console.info("task started with " + subJobs.size() + " jobs");
+     }
+     @Override
+     public void taskStatusChanged(TaskI source, JobStatus status)
+     {
+       Console.info("task status " + status);
+     }
+     @Override
+     public void taskCompleted(TaskI source, Object result)
+     {
+       Console.info("task completed");
+     }
+     @Override
+     public void taskException(TaskI source, Exception e)
+     {
+       Console.info("task failed", e);
+     }
+     @Override
 -    public void taskRestarted(TaskI source)
 -    {
 -      Console.info("task restarted");
 -    }
 -
 -    @Override
+     public void subJobStatusChanged(TaskI source, JobI job,
+             JobStatus status)
+     {
+       Console.info("sub-job " + job.getInternalId() + " status " + status);
+     }
+     @Override
+     public void subJobLogChanged(TaskI source, JobI job, String log)
+     {
+     }
+     @Override
+     public void subJobErrorLogChanged(TaskI source, JobI job, String log)
+     {
+     }
+   };
+   @SuppressWarnings("unchecked")
+   static <T> TaskEventListener<T> nullListener()
+   {
+     return (TaskEventListener<T>) NULL_LISTENER;
+   }
  }
index 0000000,5e6ef08..bdba2f7
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,73 +1,82 @@@
+ package jalview.ws2.actions.hmmer;
+ import java.util.List;
+ import java.util.Objects;
++import jalview.api.AlignViewportI;
+ import jalview.datamodel.AlignmentI;
+ import jalview.viewmodel.AlignmentViewport;
+ import jalview.ws.params.ArgumentI;
+ import jalview.ws2.actions.BaseAction;
++import jalview.ws2.actions.PollingTaskExecutor;
+ import jalview.ws2.actions.api.TaskEventListener;
+ import jalview.ws2.actions.api.TaskI;
+ import jalview.ws2.api.Credentials;
+ import jalview.ws2.client.api.AlignmentWebServiceClientI;
+ import jalview.ws2.client.api.WebServiceClientI;
+ /**
+  * Implementation of the {@link BaseAction} for the phmmer client. This is NOT
+  * how you should implement it. The action should be more generic and cover
+  * range of similar services.
+  * 
+  * @author mmwarowny
+  *
+  */
+ // FIXME: Not an alignment action (temporary hack)
+ public class PhmmerAction extends BaseAction<AlignmentI>
+ {
+   public static class Builder extends BaseAction.Builder<PhmmerAction>
+   {
+     protected AlignmentWebServiceClientI client;
+     private Builder(AlignmentWebServiceClientI client)
+     {
+       super();
+       Objects.requireNonNull(client);
+       this.client = client;
+     }
+     public PhmmerAction build()
+     {
+       return new PhmmerAction(this);
+     }
+   }
+   public static Builder newBuilder(AlignmentWebServiceClientI client)
+   {
+     return new Builder(client);
+   }
+   protected final AlignmentWebServiceClientI client;
+   public PhmmerAction(Builder builder)
+   {
+     super(builder);
+     client = builder.client;
+   }
 -  @Override
+   public TaskI<AlignmentI> perform(AlignmentViewport viewport,
+           List<ArgumentI> args, Credentials credentials,
+           TaskEventListener<AlignmentI> handler)
+   {
 -    var task = new PhmmerTask(client, args, credentials,
 -            viewport.getAlignmentView(true), handler);
 -    task.start(viewport.getServiceExecutor());
++    var task = createTask(viewport, args, credentials);
++    task.addTaskEventListener(handler);
++    var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
++    var future = executor.submit(task);
++    task.setCancelAction(() -> { future.cancel(true); });
+     return task;
+   }
++  
++  public PhmmerTask createTask(AlignViewportI viewport,
++      List<ArgumentI> args, Credentials credentials) 
++  {
++    return new PhmmerTask(client, args, credentials, viewport.getAlignmentView(true));
++  }
+   @Override
+   public boolean isActive(AlignmentViewport viewport)
+   {
+     return false;
+   }
+ }
index 0000000,8a7a826..ede61d3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,114 +1,111 @@@
+ package jalview.ws2.actions.hmmer;
+ import static jalview.util.Comparison.GapChars;
+ import java.io.IOException;
 -import java.util.Arrays;
+ import java.util.List;
+ import jalview.analysis.AlignSeq;
+ import jalview.bin.Console;
+ import jalview.datamodel.AlignmentAnnotation;
+ import jalview.datamodel.AlignmentI;
+ import jalview.datamodel.AlignmentView;
+ import jalview.datamodel.Annotation;
+ import jalview.datamodel.Sequence;
+ import jalview.datamodel.SequenceI;
+ import jalview.util.Comparison;
+ import jalview.ws.params.ArgumentI;
 -import jalview.ws2.actions.AbstractPollableTask;
+ import jalview.ws2.actions.BaseJob;
++import jalview.ws2.actions.BaseTask;
+ import jalview.ws2.actions.ServiceInputInvalidException;
 -import jalview.ws2.actions.api.TaskEventListener;
+ import jalview.ws2.api.Credentials;
+ import jalview.ws2.api.JobStatus;
+ import jalview.ws2.client.api.AlignmentWebServiceClientI;
 -class PhmmerTask extends AbstractPollableTask<BaseJob, AlignmentI>
++class PhmmerTask extends BaseTask<BaseJob, AlignmentI>
+ {
+   private final AlignmentWebServiceClientI client;
+   private final AlignmentView view;
+   PhmmerTask(AlignmentWebServiceClientI client, List<ArgumentI> args,
 -          Credentials credentials, AlignmentView view,
 -          TaskEventListener<AlignmentI> eventListener)
++          Credentials credentials, AlignmentView view)
+   {
 -    super(client, args, credentials, eventListener);
++    super(client, args, credentials);
+     this.client = client;
+     this.view = view;
+   }
+   @Override
 -  protected List<BaseJob> prepare() throws ServiceInputInvalidException
++  protected List<BaseJob> prepareJobs() throws ServiceInputInvalidException
+   {
+     Console.info("Preparing sequence for phmmer job");
+     var sequence = view.getVisibleAlignment('-').getSequenceAt(0);
+     var seq = new Sequence(sequence.getName(),
+             AlignSeq.extractGaps(GapChars, sequence.getSequenceAsString()));
+     var job = new BaseJob(List.of(seq))
+     {
+       @Override
+       public boolean isInputValid()
+       {
+         return true;
+       }
+     };
+     job.setStatus(JobStatus.READY);
+     return List.of(job);
+   }
+   @Override
 -  protected AlignmentI done() throws IOException
++  protected AlignmentI collectResult(List<BaseJob> jobs) throws IOException
+   {
 -    var job = getSubJobs().get(0);
++    var job = jobs.get(0);
+     var status = job.getStatus();
+     Console.info(String.format("phmmer finished job \"%s\" with status %s",
+             job.getServerJob().getJobId(), status));
+     if (status != JobStatus.COMPLETED)
+       return null;
+     var outputAlignment = client.getAlignment(job.getServerJob());
+     var querySeq = job.getInputSequences().get(0).deriveSequence();
+     {
+       AlignmentAnnotation refpos = null;
+       for (var annot : outputAlignment.getAlignmentAnnotation())
+       {
+         if (annot.sequenceRef == null && annot.label.equals("Reference Positions"))
+         {
+           refpos = annot;
+           break;
+         }
+       }
+       if (refpos != null)
+       {
+         querySeq = alignQeuryToReferencePositions(querySeq, refpos);
+       }
+     }
+     outputAlignment.insertSequenceAt(0, querySeq);
+     return outputAlignment;
+   }
+   private SequenceI alignQeuryToReferencePositions(SequenceI query, AlignmentAnnotation refpos)
+   {
+     var sequenceBuilder = new StringBuilder();
+     var index = 0;
+     for (Annotation a : refpos.annotations)
+     {
+       // TODO: we assume that the number of "x" annotations is equal to the number
+       // of residues. may need a safeguard against invalid input
+       if (a != null && a.displayCharacter.equals("x"))
+       {
+         char c;
+         do
+           c = query.getCharAt(index++);
+         while (Comparison.isGap(c));
+         sequenceBuilder.append(c);
+       }
+       else
+       {
+         sequenceBuilder.append(Comparison.GAP_DASH);
+       }
+     }
+     query.setSequence(sequenceBuilder.toString());
+     return query;
+   }
+ }
index 0000000,a20575d..48d0ba5
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,108 +1,107 @@@
+ package jalview.ws2.client.ebi;
+ import java.io.IOException;
+ import java.net.MalformedURLException;
+ import java.net.URISyntaxException;
+ import java.net.URL;
+ import java.util.List;
+ import jalview.bin.Console;
+ import jalview.ws.params.ParamManager;
+ import jalview.ws2.actions.NullAction;
+ import jalview.ws2.actions.api.ActionI;
+ import jalview.ws2.actions.hmmer.PhmmerAction;
+ import jalview.ws2.api.WebService;
+ import jalview.ws2.client.api.AbstractWebServiceDiscoverer;
+ import uk.ac.dundee.compbio.hmmerclient.PhmmerClient;
+ public final class JobDispatcherWSDiscoverer extends AbstractWebServiceDiscoverer
+ {
+   private static final URL DEFAULT_URL;
+   static
+   {
+     try
+     {
+       DEFAULT_URL = new URL("https://www.ebi.ac.uk/Tools/services/rest/hmmer3_phmmer/");
+     } catch (MalformedURLException e)
+     {
+       throw new ExceptionInInitializerError(e);
+     }
+   }
+   
+   private static JobDispatcherWSDiscoverer instance = null;
+   private static ParamManager paramManager = null;
+   
+   private JobDispatcherWSDiscoverer() {}
+   
+   public static JobDispatcherWSDiscoverer getInstance() {
+     if (instance == null)
+       instance = new JobDispatcherWSDiscoverer();
+     return instance;
+   }
+   
+   public static void setParamManager(ParamManager manager)
+   {
+     paramManager = manager;
+   }
+   
+   @Override
+   public int getStatusForUrl(URL url)
+   {
+     try
+     {
+       return new PhmmerClient(url).testEndpoint() ? STATUS_OK : STATUS_INVALID;
+     } catch (URISyntaxException e)
+     {
+       Console.error(e.getMessage());
+       return STATUS_INVALID;
+     } 
+   }
+   @Override
+   protected String getUrlsPropertyKey()
+   {
+     return null;
+   }
+   @Override
+   protected URL getDefaultUrl()
+   {
+     return DEFAULT_URL;
+   }
+   @Override
+   protected List<WebService<?>> fetchServices(URL url) throws IOException
+   {
+     PhmmerClient phmmerClient;
+     try {
+       phmmerClient = new PhmmerClient(url);
+     }
+     catch (URISyntaxException e) {
+       throw new MalformedURLException(e.getMessage());
+     }
+     if (!phmmerClient.testEndpoint())
+       throw new IOException(
+               "unable to reach dispatcher server at " + url);
 -    // TODO change once a concrete action is implemented
+     var wsBuilder = WebService.<PhmmerAction> newBuilder();
+     wsBuilder.url(url);
+     wsBuilder.clientName("job dispatcher");
+     wsBuilder.category("Database search");
+     wsBuilder.name("pHMMER");
+     wsBuilder.description("Hmmer3 phmmer is used to search one or more query sequences against a sequence database.");
+     wsBuilder.interactive(false);
+     wsBuilder.paramDatastore(ParamStores.newPhmmerDatastore(url, paramManager));
+     wsBuilder.actionClass(PhmmerAction.class);
+     var webService = wsBuilder.build(); 
+     
+     var client = new PhmmerWSClient(phmmerClient);
+     var actionBuilder = PhmmerAction.newBuilder(client);
+     actionBuilder.webService(webService);
+     actionBuilder.name("");
+     webService.addAction(actionBuilder.build());
+     
+     return List.of(webService);
+   }
+ }
index 0000000,519adc7..49df466
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,242 +1,236 @@@
+ package jalview.ws2.gui;
+ import java.util.List;
+ import javax.swing.SwingUtilities;
+ import jalview.bin.Console;
+ import jalview.datamodel.Alignment;
+ import jalview.datamodel.AlignmentAnnotation;
+ import jalview.datamodel.AlignmentI;
+ import jalview.gui.AlignFrame;
+ import jalview.gui.Desktop;
+ import jalview.gui.JvOptionPane;
+ import jalview.gui.WebserviceInfo;
+ import jalview.util.ArrayUtils;
+ import jalview.util.MessageManager;
+ import jalview.ws2.actions.api.ActionI;
+ import jalview.ws2.actions.api.JobI;
+ import jalview.ws2.actions.api.TaskEventListener;
+ import jalview.ws2.actions.api.TaskI;
+ import jalview.ws2.api.JobStatus;
+ import jalview.ws2.api.WebService;
+ import jalview.ws2.helpers.WSClientTaskWrapper;
+ import static java.lang.String.format;
+ class SearchServiceGuiHandler implements TaskEventListener<AlignmentI>
+ {
+   private final AlignFrame parentFrame;
+   private final ActionI<?> action;
+   private final WebService<?> service;
+   private WebserviceInfo infoPanel;
+   private JobI[] jobs = new JobI[0];
+   private int[] tabs = new int[0];
+   private int[] logOffset = new int[0];
+   private int[] errLogOffset = new int[0];
+   public SearchServiceGuiHandler(ActionI<?> action, AlignFrame parentFrame)
+   {
+     this.parentFrame = parentFrame;
+     this.action = action;
+     this.service = action.getWebService();
+     var info = String.format("%s search using service at %s%n%s",
+             service.getName(), service.getUrl(), service.getDescription());
+     this.infoPanel = new WebserviceInfo(service.getName(), info, false);
+   }
+   @Override
+   public void taskStarted(TaskI<AlignmentI> source,
+           List<? extends JobI> subJobs)
+   {
+     Console.debug(format("task %s#%x started with %d sub-jobs",
+             service.getName(), source.getUid(), subJobs.size()));
+     jobs = subJobs.toArray(new JobI[subJobs.size()]);
+     tabs = new int[subJobs.size()];
+     logOffset = new int[subJobs.size()];
+     errLogOffset = new int[subJobs.size()];
+     for (int i = 0; i < subJobs.size(); i++)
+     {
+       JobI job = jobs[i];
+       int tabIndex = infoPanel.addJobPane();
+       tabs[i] = tabIndex;
+       infoPanel.setProgressName(format("region %d", i), tabIndex);
+       infoPanel.setProgressText(tabIndex, "Job details:\n");
+       // jobs should not have states other than invalid or ready at this point
+       if (job.getStatus() == JobStatus.INVALID)
+         infoPanel.setStatus(tabIndex, WebserviceInfo.STATE_STOPPED_OK);
+       else if (job.getStatus() == JobStatus.READY)
+         infoPanel.setStatus(tabIndex, WebserviceInfo.STATE_QUEUING);
+     }
+   }
+   @Override
+   public void taskStatusChanged(TaskI<AlignmentI> source, JobStatus status)
+   {
+     Console.debug(format("task %s#%x status changed to %s",
+             service.getName(), source.getUid(), status));
+     switch (status)
+     {
+     case INVALID:
+       infoPanel.setVisible(false);
+       JvOptionPane.showMessageDialog(parentFrame,
+               MessageManager.getString("info.invalid_search_input"),
+               MessageManager.getString("info.invalid_search_input"),
+               JvOptionPane.INFORMATION_MESSAGE);
+       break;
+     case READY:
+       infoPanel.setthisService(new WSClientTaskWrapper(source));
+       infoPanel.setVisible(true);
+       // intentional no break
+     case SUBMITTED:
+     case QUEUED:
+       infoPanel.setStatus(WebserviceInfo.STATE_QUEUING);
+       break;
+     case RUNNING:
+     case UNKNOWN: // unsure what to do with unknown
+       infoPanel.setStatus(WebserviceInfo.STATE_RUNNING);
+       break;
+     case COMPLETED:
+       infoPanel.setProgressBar(
+               MessageManager.getString("status.collecting_job_results"),
+               jobs[0].getInternalId());
+       infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_OK);
+       break;
+     case FAILED:
+       infoPanel.removeProgressBar(jobs[0].getInternalId());
+       infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_ERROR);
+       break;
+     case CANCELLED:
+       infoPanel.setStatus(WebserviceInfo.STATE_CANCELLED_OK);
+       break;
+     case SERVER_ERROR:
+       infoPanel.removeProgressBar(jobs[0].getInternalId());
+       infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_SERVERERROR);
+       break;
+     }
+   }
+   @Override
+   public void taskCompleted(TaskI<AlignmentI> source, AlignmentI result)
+   {
+     Console.debug(format("task %s#%x completed", service.getName(),
+             source.getUid()));
+     SwingUtilities.invokeLater(
+             () -> infoPanel.removeProgressBar(jobs[0].getInternalId()));
+     if (result == null)
+     {
+       SwingUtilities.invokeLater(infoPanel::setFinishedNoResults);
+       return;
+     }
+     infoPanel.showResultsNewFrame.addActionListener(evt -> {
+       // copy alignment for each frame to have its own instance
+       var alnCpy = new Alignment(result);
+       alnCpy.setGapCharacter(result.getGapCharacter());
+       alnCpy.setDataset(result.getDataset());
+       for (AlignmentAnnotation annotation : result.getAlignmentAnnotation())
+         alnCpy.addAnnotation(new AlignmentAnnotation(annotation));
+       displayResultsNewFrame(alnCpy);
+     });
+     SwingUtilities.invokeLater(infoPanel::setResultsReady);
+   }
+   private void displayResultsNewFrame(AlignmentI aln)
+   {
+     AlignFrame frame = new AlignFrame(aln, AlignFrame.DEFAULT_WIDTH,
+             AlignFrame.DEFAULT_HEIGHT);
+     frame.getFeatureRenderer().transferSettings(
+             parentFrame.getFeatureRenderer().getSettings());
+     var actionName = action.getName() != null ? action.getName() : "Search";
+     var title = String.format("%s %s of %s", service.getName(), actionName,
+             parentFrame.getTitle());
+     Desktop.addInternalFrame(frame, title, AlignFrame.DEFAULT_WIDTH,
+             AlignFrame.DEFAULT_HEIGHT);
+   }
+   @Override
+   public void taskException(TaskI<AlignmentI> source, Exception e)
+   {
+     Console.error(format("Task %s#%x raised an exception.",
+             service.getName(), source.getUid()), e);
+     infoPanel.appendProgressText(e.getMessage());
+   }
+   @Override
 -  public void taskRestarted(TaskI<AlignmentI> source)
 -  {
 -    // search services non-restartable
 -  }
 -
 -  @Override
+   public void subJobStatusChanged(TaskI<AlignmentI> source, JobI job,
+           JobStatus status)
+   {
+     Console.debug(format("sub-job %x status changed to %s",
+             job.getInternalId(), status));
+     int i = ArrayUtils.indexOf(jobs, job);
+     assert i >= 0 : "job does not exist";
+     if (i < 0)
+       // safeguard that should not happen irl
+       return;
+     int wsStatus;
+     switch (status)
+     {
+     case INVALID:
+     case COMPLETED:
+       wsStatus = WebserviceInfo.STATE_STOPPED_OK;
+       break;
+     case READY:
+     case SUBMITTED:
+     case QUEUED:
+       wsStatus = WebserviceInfo.STATE_QUEUING;
+       break;
+     case RUNNING:
+     case UNKNOWN:
+       wsStatus = WebserviceInfo.STATE_RUNNING;
+       break;
+     case FAILED:
+       wsStatus = WebserviceInfo.STATE_STOPPED_ERROR;
+       break;
+     case CANCELLED:
+       wsStatus = WebserviceInfo.STATE_CANCELLED_OK;
+       break;
+     case SERVER_ERROR:
+       wsStatus = WebserviceInfo.STATE_STOPPED_SERVERERROR;
+       break;
+     default:
+       throw new AssertionError("Non-exhaustive switch statement");
+     }
+     infoPanel.setStatus(tabs[i], wsStatus);
+   }
+   @Override
+   public void subJobLogChanged(TaskI<AlignmentI> source, JobI job,
+           String log)
+   {
+     int i = ArrayUtils.indexOf(jobs, job);
+     assert i >= 0 : "job does not exist";
+     if (i < 0)
+       // safeguard that should never happen
+       return;
+     infoPanel.appendProgressText(tabs[i], log.substring(logOffset[i]));
+   }
+   @Override
+   public void subJobErrorLogChanged(TaskI<AlignmentI> source, JobI job,
+           String log)
+   {
+     int i = ArrayUtils.indexOf(jobs, job);
+     assert i >= 0 : "job does not exist";
+     if (i < 0)
+       // safeguard that should never happen
+       return;
+     infoPanel.appendProgressText(tabs[i], log.substring(errLogOffset[i]));
+   }
+ }
@@@ -21,6 -24,7 +21,8 @@@ import javax.swing.JMenuItem
  import javax.swing.ToolTipManager;
  import javax.swing.border.EmptyBorder;
  
+ import jalview.bin.Console;
++import jalview.datamodel.AlignmentI;
  import jalview.gui.AlignFrame;
  import jalview.gui.Desktop;
  import jalview.gui.JvSwingUtils;
@@@ -30,14 -34,12 +32,16 @@@ import jalview.viewmodel.AlignmentViewp
  import jalview.ws.params.ArgumentI;
  import jalview.ws.params.ParamDatastoreI;
  import jalview.ws.params.WsParamSetI;
 +import jalview.ws2.actions.BaseTask;
 +import jalview.ws2.actions.PollingTaskExecutor;
  import jalview.ws2.actions.alignment.AlignmentAction;
 +import jalview.ws2.actions.alignment.AlignmentResult;
 +import jalview.ws2.actions.annotation.AlignCalcWorkerAdapter;
  import jalview.ws2.actions.annotation.AnnotationAction;
  import jalview.ws2.actions.api.ActionI;
+ import jalview.ws2.actions.api.TaskEventListener;
  import jalview.ws2.actions.api.TaskI;
+ import jalview.ws2.actions.hmmer.PhmmerAction;
  import jalview.ws2.api.Credentials;
  import jalview.ws2.api.WebService;
  import jalview.ws2.client.api.WebServiceProviderI;
@@@ -444,29 -467,21 +448,44 @@@ public class WebServicesMenuManage
      }
      if (action instanceof AnnotationAction)
      {
 +      var calcManager = viewport.getCalcManager();
 +
        var _action = (AnnotationAction) action;
 +      var worker = new AlignCalcWorkerAdapter(viewport, frame.alignPanel,
 +          _action, args, credentials);
        var handler = new AnnotationServiceGuiHandler(_action, frame);
 -      return _action.perform(viewport, args, credentials, handler);
 +      worker.setWorkerListener(handler);
 +      for (var w : calcManager.getWorkers())
 +      {
 +        if (worker.getCalcName() != null && worker.getCalcName().equals(w.getCalcName()))
 +        {
 +          calcManager.cancelWorker(w);
 +          calcManager.removeWorker(w);
 +        }
 +      }
 +      if (action.getWebService().isInteractive())
 +        calcManager.registerWorker(worker);
 +      else
 +        calcManager.startWorker(worker);
 +      return;
      }
-     throw new IllegalArgumentException(
-         String.format("Illegal action type %s", action.getClass().getName()));
+     if (action instanceof PhmmerAction)
+     {
+       var _action = (PhmmerAction) action;
+       var handler = new SearchServiceGuiHandler(_action, frame);
 -      return _action.perform(viewport, args, credentials, handler);
++      TaskI<AlignmentI> task = _action.createTask(viewport, args, credentials);
++      var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
++      task.addTaskEventListener(handler);
++      _action.perform(viewport, args, credentials, handler);
++      return;
+     }
+     Console.warn(String.format(
+             "No known handler for action type %s. All output will be discarded.",
+             action.getClass().getName()));
 -    return action.perform(viewport, args, credentials,
 -            TaskEventListener.nullListener());
++    var task = action.createTask(viewport, args, credentials);
++    task.addTaskEventListener(TaskEventListener.nullListener());
++    PollingTaskExecutor.fromPool(viewport.getServiceExecutor())
++      .submit(task);
    }
  
    private static CompletionStage<List<ArgumentI>> openEditParamsDialog(