Merge branch 'mmw/JAL-4199-task-execution-update' into development/Release_2_12_Branch
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Tue, 18 Jul 2023 14:46:30 +0000 (16:46 +0200)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Tue, 18 Jul 2023 14:46:30 +0000 (16:46 +0200)
40 files changed:
j11lib/hmmer-client-1.0-SNAPSHOT.jar [new file with mode: 0644]
j11lib/simple-http-client-1.0-SNAPSHOT.jar [new file with mode: 0644]
resources/lang/Messages.properties
src/jalview/api/AlignCalcWorkerI.java
src/jalview/datamodel/AnnotatedCollectionI.java
src/jalview/datamodel/ContiguousI.java
src/jalview/datamodel/SequenceI.java
src/jalview/gui/AlignFrame.java
src/jalview/gui/Desktop.java
src/jalview/ws/params/simple/BooleanOption.java
src/jalview/ws/params/simple/DoubleParameter.java
src/jalview/ws/params/simple/IntegerParameter.java
src/jalview/ws2/actions/AbstractPollableTask.java [deleted file]
src/jalview/ws2/actions/BaseJob.java
src/jalview/ws2/actions/BaseTask.java [new file with mode: 0644]
src/jalview/ws2/actions/NullAction.java [new file with mode: 0644]
src/jalview/ws2/actions/NullTask.java [new file with mode: 0644]
src/jalview/ws2/actions/PollingTaskExecutor.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentAction.java
src/jalview/ws2/actions/alignment/AlignmentTask.java
src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationAction.java
src/jalview/ws2/actions/annotation/AnnotationProviderI.java
src/jalview/ws2/actions/annotation/AnnotationTask.java
src/jalview/ws2/actions/api/ActionI.java
src/jalview/ws2/actions/api/TaskEventListener.java
src/jalview/ws2/actions/api/TaskI.java
src/jalview/ws2/actions/hmmer/PhmmerAction.java [new file with mode: 0644]
src/jalview/ws2/actions/hmmer/PhmmerTask.java [new file with mode: 0644]
src/jalview/ws2/api/Credentials.java
src/jalview/ws2/client/ebi/JobDispatcherWSDiscoverer.java [new file with mode: 0644]
src/jalview/ws2/client/ebi/ParamStores.java [new file with mode: 0644]
src/jalview/ws2/client/ebi/PhmmerWSClient.java [new file with mode: 0644]
src/jalview/ws2/client/slivka/SlivkaWSClient.java
src/jalview/ws2/gui/AlignmentServiceGuiHandler.java
src/jalview/ws2/gui/AnnotationServiceGuiHandler.java
src/jalview/ws2/gui/SearchServiceGuiHandler.java [new file with mode: 0644]
src/jalview/ws2/gui/WebServicesMenuManager.java
src/jalview/ws2/helpers/TaskEventSupport.java
test/jalview/ws2/actions/alignment/AlignmentActionTest.java

diff --git a/j11lib/hmmer-client-1.0-SNAPSHOT.jar b/j11lib/hmmer-client-1.0-SNAPSHOT.jar
new file mode 100644 (file)
index 0000000..1efea10
Binary files /dev/null and b/j11lib/hmmer-client-1.0-SNAPSHOT.jar differ
diff --git a/j11lib/simple-http-client-1.0-SNAPSHOT.jar b/j11lib/simple-http-client-1.0-SNAPSHOT.jar
new file mode 100644 (file)
index 0000000..43b5933
Binary files /dev/null and b/j11lib/simple-http-client-1.0-SNAPSHOT.jar differ
index f8c2b68..59c2bd3 100644 (file)
@@ -1109,6 +1109,7 @@ info.alignment_object_method_notes = \nAlignment Object Method Notes\n
 info.server_exception = \n{0} Server exception\!\n{1}
 info.invalid_msa_input_mininfo = Need at least two sequences with at least 3 residues each, with no hidden regions between them.  
 info.invalid_msa_notenough = Not enough sequence data to align
+info.invalid_search_input = Invalid input for sequence search.
 status.processing_commandline_args = Processing commandline arguments...
 status.das_features_being_retrived = DAS features being retrieved...
 status.searching_for_sequences_from = Searching for sequences from {0}
index b51b94e..89470e0 100644 (file)
@@ -65,4 +65,12 @@ public interface AlignCalcWorkerI
    * @return
    */
   boolean isDeletable();
+
+  /**
+   * Returns the name of this calculation.
+   */
+  public default String getCalcName()
+  {
+    return null;
+  }
 }
index 878f22a..755333c 100644 (file)
@@ -62,5 +62,21 @@ public interface AnnotatedCollectionI extends SequenceCollectionI
    */
   AnnotatedCollectionI getContext();
 
-  
+  /**
+   * Returns the number of the highest annotation graph group attached to this
+   * alignment.
+   */
+  default int getLastGraphGroup()
+  {
+    var annots = getAlignmentAnnotation();
+    if (annots == null)
+      return 1;
+    int graphGroup = 1;
+    for (AlignmentAnnotation ala : annots)
+    {
+      graphGroup = Math.max(graphGroup, ala.graphGroup);
+    }
+    return graphGroup;
+  }
+
 }
index a9b1372..bc72984 100644 (file)
  */
 package jalview.datamodel;
 
+import java.util.Collection;
+
 public interface ContiguousI
 {
   int getBegin(); // todo want long for genomic positions?
 
   int getEnd();
+  
+  public static int[] toStartEndArray(Collection<? extends ContiguousI> ranges)
+  {
+    int[] startend = new int[ranges.size() * 2];
+    int i = 0;
+    for (var range : ranges)
+    {
+      startend[i++] = range.getBegin();
+      startend[i++] = range.getEnd();
+    }
+    return startend;
+  }
 }
index 8f1d160..d8a5462 100755 (executable)
@@ -396,6 +396,19 @@ public interface SequenceI extends ASequenceI
   public SequenceI getDatasetSequence();
 
   /**
+   * Returns the top grandparent in the dataset sequences hierarchy.
+   */
+  public default SequenceI getRootDatasetSequence()
+  {
+    var sequence = this;
+    while (sequence.getDatasetSequence() != null)
+    {
+      sequence = sequence.getDatasetSequence();
+    }
+    return sequence;
+  }
+
+  /**
    * Returns a new array containing this sequence's annotations, or null.
    */
   public AlignmentAnnotation[] getAnnotation();
index 63bbb72..8ae4601 100644 (file)
@@ -183,6 +183,7 @@ import jalview.ws.params.ParamDatastoreI;
 import jalview.ws.params.WsParamSetI;
 import jalview.ws.seqfetcher.DbSourceProxy;
 import jalview.ws2.client.api.WebServiceDiscovererI;
+import jalview.ws2.client.ebi.JobDispatcherWSDiscoverer;
 import jalview.ws2.client.slivka.SlivkaWSDiscoverer;
 import jalview.ws2.gui.WebServicesMenuManager;
 
@@ -977,6 +978,15 @@ public class AlignFrame extends GAlignFrame
         menu.setNoServices(services.isEmpty() && discoverer.isDone());
       };
 
+  private WebServiceDiscovererI.ServicesChangeListener ebiServiceChangeListener =
+      (discoverer, services) -> {
+        // run when ebi services change
+        var menu = AlignFrame.this.ebiMenu;
+        menu.setServices(discoverer);
+        menu.setInProgress(discoverer.isRunning());
+        menu.setNoServices(services.isEmpty() && discoverer.isDone());
+      };
+
   /* Set up intrinsic listeners for dynamically generated GUI bits. */
   private void addServiceListeners()
   {
@@ -985,6 +995,10 @@ public class AlignFrame extends GAlignFrame
       WebServiceDiscovererI discoverer = SlivkaWSDiscoverer.getInstance();
       discoverer.addServicesChangeListener(slivkaServiceChangeListener);
     }
+    if (Cache.getDefault("SHOW_EBI_SERVICES", true))
+    {
+      JobDispatcherWSDiscoverer.getInstance().addServicesChangeListener(ebiServiceChangeListener);
+    }
     if (Cache.getDefault("SHOW_JWS2_SERVICES", true))
     {
       WSDiscovererI discoverer = Jws2Discoverer.getInstance();
@@ -4657,6 +4671,7 @@ public class AlignFrame extends GAlignFrame
   }
 
   private WebServicesMenuManager slivkaMenu = new WebServicesMenuManager("slivka", this);
+  private WebServicesMenuManager ebiMenu = new WebServicesMenuManager("job dispatcher", this);
 
   /**
    * Schedule the web services menu rebuild to the event dispatch thread.
@@ -4675,6 +4690,15 @@ public class AlignFrame extends GAlignFrame
         slivkaMenu.setNoServices(discoverer.isDone() && !discoverer.hasServices());
         webService.add(slivkaMenu.getMenu());
       }
+      if (Cache.getDefault("SHOW_EBI_SERVICES", true))
+      {
+        Console.info("Building web services menu for jobs dispatcher");
+        JobDispatcherWSDiscoverer discoverer = JobDispatcherWSDiscoverer.getInstance();
+        ebiMenu.setServices(discoverer);
+        ebiMenu.setInProgress(discoverer.isRunning());
+        ebiMenu.setNoServices(discoverer.isDone() && !discoverer.hasServices());
+        webService.add(ebiMenu.getMenu());
+      }
       if (Cache.getDefault("SHOW_JWS2_SERVICES", true))
       {
         WSDiscovererI jws2servs = Jws2Discoverer.getInstance();
index 0b639dd..02643ec 100644 (file)
@@ -2742,6 +2742,11 @@ public class Desktop extends GDesktop
       tasks.add(jalview.ws2.client.slivka.SlivkaWSDiscoverer
           .getInstance().startDiscoverer());
     }
+    if (Cache.getDefault("SHOW_EBI_SERVICES", true))
+    {
+      tasks.add(jalview.ws2.client.ebi.JobDispatcherWSDiscoverer
+              .getInstance().startDiscoverer());
+    }
     if (blocking)
     {
       for (Future<?> task : tasks) {
index 87e4ad1..8609ff0 100644 (file)
@@ -24,6 +24,8 @@ import java.net.URL;
 import java.util.Arrays;
 import java.util.List;
 
+import jalview.ws.params.ArgumentI;
+
 public class BooleanOption extends Option
 {
   public static class Builder extends Option.Builder
@@ -98,4 +100,10 @@ public class BooleanOption extends Option
   {
     this(name, description, label, isrequired, defValue, String.valueOf(true), link);
   }
+  
+  public static Boolean parseBoolean(ArgumentI argument)
+  {
+    return argument.getValue() != null && !argument.getValue().isEmpty() ?
+            true : false;
+  }
 }
index 97c5fe1..f08f5fd 100644 (file)
@@ -1,5 +1,6 @@
 package jalview.ws.params.simple;
 
+import jalview.ws.params.ArgumentI;
 import jalview.ws.params.ParameterI;
 import jalview.ws.params.ValueConstrainI;
 
@@ -152,4 +153,28 @@ public class DoubleParameter extends Option implements ParameterI
   {
     return new DoubleParameter(this);
   }
+  
+  /**
+   * Return argument value as double or null if string value is null or empty.
+   * 
+   * @param arg argument to extract value form
+   * @return argument value as double
+   */
+  public static Double parseDouble(ArgumentI arg)
+  {
+    return arg.getValue() != null && !arg.getValue().isEmpty() ?
+            Double.parseDouble(arg.getValue()) : null; 
+  }
+  
+  /**
+   * Return argument value as float or null if string value is null or empty.
+   * 
+   * @param arg argument to extract value from
+   * @return value as float
+   */
+  public static Float parseFloat(ArgumentI arg)
+  {
+    return arg.getValue() != null && !arg.getValue().isEmpty() ?
+            Float.parseFloat(arg.getValue()) : null;
+  }
 }
index e154194..774c21f 100644 (file)
@@ -20,6 +20,7 @@
  */
 package jalview.ws.params.simple;
 
+import jalview.ws.params.ArgumentI;
 import jalview.ws.params.ParameterI;
 import jalview.ws.params.ValueConstrainI;
 
@@ -165,4 +166,16 @@ public class IntegerParameter extends Option implements ParameterI
     return new IntegerParameter(this);
   }
 
+  /**
+   * Return argument value as int or null if string value is null or empty.
+   * 
+   * @param arg argument to extract value from
+   * @return value as int
+   */
+  public static Integer parseInt(ArgumentI arg)
+  {
+    return arg.getValue() != null && !arg.getValue().isEmpty() ?
+            Integer.parseInt(arg.getValue()) : null;
+  }
+
 }
diff --git a/src/jalview/ws2/actions/AbstractPollableTask.java b/src/jalview/ws2/actions/AbstractPollableTask.java
deleted file mode 100644 (file)
index b61711c..0000000
+++ /dev/null
@@ -1,352 +0,0 @@
-package jalview.ws2.actions;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.CancellationException;
-import java.util.concurrent.CompletionException;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-import jalview.bin.Cache;
-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.api.WebServiceJobHandle;
-import jalview.ws2.client.api.WebServiceClientI;
-import jalview.ws2.helpers.DelegateJobEventListener;
-import jalview.ws2.helpers.TaskEventSupport;
-import static java.lang.String.format;
-
-/**
- * An abstract base class for non-interactive tasks which implements common
- * tasks methods. Additionally, it manages task execution in a polling loop.
- * Subclasses are only required to implement {@link #prepare()} and
- * {@link #done()} methods.
- * 
- * @author mmwarowny
- *
- * @param <T>
- *          the type of jobs managed by the task
- * @param <R>
- *          the type of result provided by the task
- */
-public abstract class AbstractPollableTask<T extends BaseJob, R> implements TaskI<R>
-{
-  private final long uid = MathUtils.getUID();
-
-  protected final WebServiceClientI client;
-
-  protected final List<ArgumentI> args;
-
-  protected final Credentials credentials;
-
-  private final TaskEventSupport<R> eventHandler;
-
-  protected JobStatus taskStatus = JobStatus.CREATED;
-
-  private Future<?> future = null;
-
-  protected List<T> jobs = Collections.emptyList();
-
-  protected R result;
-
-  protected AbstractPollableTask(WebServiceClientI client, List<ArgumentI> args,
-      Credentials credentials, TaskEventListener<R> eventListener)
-  {
-    this.client = client;
-    this.args = args;
-    this.credentials = credentials;
-    this.eventHandler = new TaskEventSupport<R>(this, eventListener);
-  }
-
-  public long getUid()
-  {
-    return uid;
-  }
-
-  /**
-   * Start the task using provided scheduled executor service. It creates a
-   * polling loop running at set intervals.
-   * 
-   * @param executor
-   *          executor to run the polling loop with
-   */
-  public void start(ScheduledExecutorService executor)
-  {
-    if (future != null)
-      throw new IllegalStateException("task already started");
-    var runnable = new Runnable()
-    {
-      private int stage = STAGE_PREPARE;
-
-      private static final int STAGE_PREPARE = 0;
-
-      private static final int STAGE_START = 1;
-
-      private static final int STAGE_POLL = 2;
-
-      private static final int STAGE_FINALIZE = 3;
-
-      private static final int STAGE_DONE = 4;
-
-      private int retryCount = 0;
-
-      private static final int MAX_RETRY = 5;
-
-      /**
-       * A polling loop run periodically which carries the task through its
-       * consecutive execution stages.
-       */
-      @Override
-      public void run()
-      {
-        if (stage == STAGE_PREPARE)
-        {
-          // first stage - the input data is collected and the jobs are created
-          try
-          {
-            jobs = prepare();
-          } catch (ServiceInputInvalidException e)
-          {
-            stage = STAGE_DONE;
-            setStatus(JobStatus.INVALID);
-            eventHandler.fireTaskException(e);
-            throw new CompletionException(e);
-          }
-          stage = STAGE_START;
-          setStatus(JobStatus.READY);
-          eventHandler.fireTaskStarted(jobs);
-          var jobListener = new DelegateJobEventListener<>(eventHandler);
-          for (var job : jobs)
-          {
-            job.addPropertyChagneListener(jobListener);
-          }
-        }
-        try
-        {
-          if (stage == STAGE_START)
-          {
-            // second stage - jobs are submitted to the server
-            startJobs();
-            stage = STAGE_POLL;
-            setStatus(JobStatus.SUBMITTED);
-          }
-          if (stage == STAGE_POLL)
-          {
-            // third stage - jobs are poolled until all of them are completed
-            if (pollJobs())
-            {
-              stage = STAGE_FINALIZE;
-            }
-            updateGlobalStatus();
-          }
-          if (stage == STAGE_FINALIZE)
-          {
-            // final stage - results are collected and stored
-            result = done();
-            eventHandler.fireTaskCompleted(result);
-            stage = STAGE_DONE;
-          }
-          retryCount = 0;
-        } catch (IOException e)
-        {
-          eventHandler.fireTaskException(e);
-          if (++retryCount > MAX_RETRY)
-          {
-            stage = STAGE_DONE;
-            cancelJobs();
-            setStatus(JobStatus.SERVER_ERROR);
-            throw new CompletionException(e);
-          }
-        }
-        if (stage == STAGE_DONE)
-        {
-          // finalization - terminating the future task
-          throw new CancellationException("task terminated");
-        }
-      }
-    };
-    if (taskStatus != JobStatus.CANCELLED)
-      future = executor.scheduleWithFixedDelay(runnable, 0, 2, TimeUnit.SECONDS);
-  }
-
-  @Override
-  public JobStatus getStatus()
-  {
-    return taskStatus;
-  }
-
-  /**
-   * Set the status of the task and notify the event handler.
-   * 
-   * @param status
-   *          new task status
-   */
-  protected void setStatus(JobStatus status)
-  {
-    if (this.taskStatus != status)
-    {
-      this.taskStatus = status;
-      eventHandler.fireTaskStatusChanged(status);
-    }
-  }
-
-  /**
-   * 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>
-   */
-  private 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]);
-    }
-  }
-
-  @Override
-  public void cancel()
-  {
-    setStatus(JobStatus.CANCELLED);
-    if (future != null)
-      future.cancel(false);
-    cancelJobs();
-  }
-
-  @Override
-  public List<? extends BaseJob> getSubJobs()
-  {
-    return jobs;
-  }
-
-  /**
-   * Collect and process input sequences for submission and return the list of
-   * jobs to be submitted.
-   * 
-   * @return list of jobs to be submitted
-   * @throws ServiceInputInvalidException
-   *           input is invalid and the task should not be started
-   */
-  protected abstract List<T> prepare() throws ServiceInputInvalidException;
-
-  /**
-   * Submit all valid jobs to the server and store their job handles.
-   * 
-   * @throws IOException
-   *           if server error occurred
-   */
-  protected void startJobs() throws IOException
-  {
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
-      {
-        WebServiceJobHandle serverJob = client.submit(job.getInputSequences(),
-            args, credentials);
-        job.setServerJob(serverJob);
-        job.setStatus(JobStatus.SUBMITTED);
-      }
-    }
-  }
-
-  /**
-   * 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
-   */
-  protected boolean pollJobs() throws IOException
-  {
-    boolean allDone = true;
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && !job.getStatus().isDone())
-      {
-        WebServiceJobHandle serverJob = job.getServerJob();
-        job.setStatus(client.getStatus(serverJob));
-        job.setLog(client.getLog(serverJob));
-        job.setErrorLog(client.getErrorLog(serverJob));
-      }
-      allDone &= job.isCompleted();
-    }
-    return allDone;
-  }
-
-  /**
-   * Fetch and process the outputs produced by jobs and return the final result
-   * of the task. The method is called once all jobs have finished execution. If
-   * this method raises {@link IOException} it will be called again after a
-   * delay. All IO operations should happen before data processing, so
-   * potentially expensive computation is avoided in case of an error.
-   * 
-   * @return final result of the computation
-   * @throws IOException
-   *           if server error occurred
-   */
-  protected abstract R done() throws IOException;
-
-  /**
-   * Cancel all running jobs. Used in case of task failure to cleanup the
-   * resources or when the task has been cancelled.
-   */
-  protected void cancelJobs()
-  {
-    for (BaseJob job : jobs)
-    {
-      if (!job.isCompleted())
-      {
-        try
-        {
-          if (job.getServerJob() != null)
-          {
-            client.cancel(job.getServerJob());
-          }
-          job.setStatus(JobStatus.CANCELLED);
-        } catch (IOException e)
-        {
-          Console.error(format("failed to cancel job %s", job.getServerJob()), e);
-        }
-      }
-    }
-  }
-
-  @Override
-  public R getResult()
-  {
-    return result;
-  }
-
-  @Override
-  public String toString()
-  {
-    var status = taskStatus != null ? taskStatus.name() : "UNSET";
-    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
-  }
-}
index 8376d20..79c1aa8 100644 (file)
@@ -25,6 +25,7 @@ import jalview.ws2.api.WebServiceJobHandle;
  * 
  * @author mmwarowny
  */
+// TODO: make class non-abstract by removing isInputValid()
 public abstract class BaseJob implements JobI
 {
   protected final long internalId = MathUtils.getUID();
@@ -70,6 +71,7 @@ public abstract class BaseJob implements JobI
    * 
    * @return {@code true} if the input is valid.
    */
+  // FIXME: method not necessary, may incorporate into task#prepare()
   public abstract boolean isInputValid();
 
   /**
@@ -175,7 +177,7 @@ public abstract class BaseJob implements JobI
    * @param listener
    *          property change listener
    */
-  public final void addPropertyChagneListener(PropertyChangeListener listener)
+  public final void addPropertyChangeListener(PropertyChangeListener listener)
   {
     pcs.addPropertyChangeListener(listener);
   }
diff --git a/src/jalview/ws2/actions/BaseTask.java b/src/jalview/ws2/actions/BaseTask.java
new file mode 100644 (file)
index 0000000..387ca69
--- /dev/null
@@ -0,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);
+  }
+}
diff --git a/src/jalview/ws2/actions/NullAction.java b/src/jalview/ws2/actions/NullAction.java
new file mode 100644 (file)
index 0000000..f91cef6
--- /dev/null
@@ -0,0 +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.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> createTask(AlignViewportI viewport,
+          List<ArgumentI> args, Credentials credentials)
+  {
+    return new NullTask();
+  }
+
+  @Override
+  public boolean isActive(AlignmentViewport viewport)
+  {
+    return false;
+  }
+}
diff --git a/src/jalview/ws2/actions/NullTask.java b/src/jalview/ws2/actions/NullTask.java
new file mode 100644 (file)
index 0000000..5dd5ab0
--- /dev/null
@@ -0,0 +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.
+ * 
+ * @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)
+  {
+  }
+}
\ No newline at end of file
diff --git a/src/jalview/ws2/actions/PollingTaskExecutor.java b/src/jalview/ws2/actions/PollingTaskExecutor.java
new file mode 100644 (file)
index 0000000..ff7a3db
--- /dev/null
@@ -0,0 +1,108 @@
+package jalview.ws2.actions;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.WeakHashMap;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import jalview.ws2.actions.api.TaskI;
+
+public class PollingTaskExecutor
+{
+  private static final Map<ScheduledExecutorService, PollingTaskExecutor> executorPool =
+      Collections.synchronizedMap(new WeakHashMap<>());
+  
+  public static PollingTaskExecutor fromPool(ScheduledExecutorService executor)
+  {
+    return executorPool.computeIfAbsent(executor, PollingTaskExecutor::new);
+  }
+  
+  private final ScheduledExecutorService executor;
+
+  public PollingTaskExecutor(ScheduledExecutorService executor)
+  {
+    this.executor = executor;
+  }
+
+  public Future<?> submit(TaskI<?> task)
+  {
+    Objects.requireNonNull(task);
+    return executor.scheduleWithFixedDelay(
+        new TaskRunnable(task), 0, 2, TimeUnit.SECONDS);
+  }
+
+  private static class TaskRunnable implements Runnable
+  {
+    private final TaskI<?> task;
+
+    private static final int STAGE_INIT = 0;
+
+    private static final int STAGE_POLLING = 2;
+
+    private static final int STAGE_FINISHED = 3;
+
+    private static final int STAGE_STOPPED = 4;
+
+    private int stage = STAGE_INIT;
+
+    private static final int MAX_POLL_RETRY = 5;
+
+    private int pollRetryCount = 0;
+
+    private TaskRunnable(TaskI<?> task)
+    {
+      this.task = task;
+    }
+
+    @Override
+    public void run()
+    {
+      if (task.getStatus().isDone())
+      {
+        stage = STAGE_STOPPED;
+      }
+      if (stage == STAGE_INIT)
+      {
+        try
+        {
+          task.init();
+          stage = STAGE_POLLING;
+        } catch (Exception e)
+        {
+          stage = STAGE_STOPPED;
+          throw new CompletionException(e);
+        }
+      }
+      try
+      {
+        if (stage == STAGE_POLLING && task.poll())
+        {
+          stage = STAGE_FINISHED;
+        }
+        if (stage == STAGE_FINISHED)
+        {
+          task.complete();
+          stage = STAGE_STOPPED;
+        }
+      } catch (Exception e)
+      {
+        if (++pollRetryCount > MAX_POLL_RETRY || e instanceof RuntimeException)
+        {
+          task.cancel();
+          stage = STAGE_STOPPED;
+          throw new CompletionException(e);
+        }
+      }
+      if (stage == STAGE_STOPPED)
+      {
+        throw new CancellationException();
+      }
+    }
+  }
+}
index 7f935bf..ce4d499 100644 (file)
@@ -3,9 +3,12 @@ package jalview.ws2.actions.alignment;
 import java.util.List;
 import java.util.Objects;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.BaseTask;
+import jalview.ws2.actions.PollingTaskExecutor;
 import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.actions.api.TaskI;
 import jalview.ws2.api.Credentials;
@@ -68,15 +71,11 @@ public class AlignmentAction extends BaseAction<AlignmentResult>
   }
 
   @Override
-  public TaskI<AlignmentResult> perform(AlignmentViewport viewport,
-      List<ArgumentI> args, Credentials credentials,
-      TaskEventListener<AlignmentResult> handler)
+  public AlignmentTask createTask(AlignViewportI viewport,
+      List<ArgumentI> args, Credentials credentials)
   {
-    var msa = viewport.getAlignmentView(true);
-    var task = new AlignmentTask(
-        client, this, args, credentials, msa, viewport, submitGaps, handler);
-    task.start(viewport.getServiceExecutor());
-    return task;
+    return new AlignmentTask(
+        client, this, args, credentials, viewport, submitGaps);
   }
 
   /**
index 6a0c4dd..944e5dd 100644 (file)
@@ -5,7 +5,6 @@ import static java.lang.String.format;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -14,7 +13,6 @@ import jalview.analysis.AlignmentSorter;
 import jalview.analysis.SeqsetUtils;
 import jalview.analysis.SeqsetUtils.SequenceInfo;
 import jalview.api.AlignViewportI;
-import jalview.bin.Cache;
 import jalview.bin.Console;
 import jalview.datamodel.AlignedCodonFrame;
 import jalview.datamodel.Alignment;
@@ -25,9 +23,8 @@ import jalview.datamodel.HiddenColumns;
 import jalview.datamodel.Sequence;
 import jalview.datamodel.SequenceI;
 import jalview.ws.params.ArgumentI;
-import jalview.ws2.actions.AbstractPollableTask;
+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;
@@ -39,7 +36,7 @@ import jalview.ws2.client.api.AlignmentWebServiceClientI;
  * @author mmwarowny
  *
  */
-class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
+class AlignmentTask extends BaseTask<AlignmentJob, AlignmentResult>
 {
   /* task parameters set in the constructor */
   private final AlignmentWebServiceClientI client;
@@ -48,8 +45,6 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
 
   private final AlignmentView msa; // a.k.a. input
 
-  private final AlignViewportI viewport;
-
   private final boolean submitGaps;
 
   private final AlignmentI currentView;
@@ -62,14 +57,12 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
 
   AlignmentTask(AlignmentWebServiceClientI client, AlignmentAction action,
       List<ArgumentI> args, Credentials credentials,
-      AlignmentView msa, AlignViewportI viewport, boolean submitGaps,
-      TaskEventListener<AlignmentResult> eventListener)
+      AlignViewportI viewport, boolean submitGaps)
   {
-    super(client, args, credentials, eventListener);
+    super(client, args, credentials);
     this.client = client;
     this.action = action;
-    this.msa = msa;
-    this.viewport = viewport;
+    this.msa = viewport.getAlignmentView(true);
     this.submitGaps = submitGaps;
     this.currentView = viewport.getAlignment();
     this.dataset = viewport.getAlignment().getDataset();
@@ -80,7 +73,7 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
   }
   
   @Override
-  protected List<AlignmentJob> prepare() throws ServiceInputInvalidException
+  protected List<AlignmentJob> prepareJobs() throws ServiceInputInvalidException
   { 
     Console.info(format("starting alignment service %s:%s",
         client.getClientName(), action.getName()));
@@ -107,7 +100,7 @@ class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
   }
 
   @Override
-  protected AlignmentResult done() throws IOException
+  protected AlignmentResult collectResult(List<AlignmentJob> jobs) throws IOException
   {
     IOException lastIOE = null;
     for (AlignmentJob job : jobs)
diff --git a/src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java b/src/jalview/ws2/actions/annotation/AlignCalcWorkerAdapter.java
new file mode 100644 (file)
index 0000000..0905ea2
--- /dev/null
@@ -0,0 +1,188 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+import jalview.analysis.AlignmentAnnotationUtils;
+import jalview.api.AlignViewportI;
+import jalview.api.AlignmentViewPanel;
+import jalview.api.PollableAlignCalcWorkerI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.workers.AlignCalcWorker;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+
+public class AlignCalcWorkerAdapter extends AlignCalcWorker implements PollableAlignCalcWorkerI
+{
+  private static int calcCount = 0;
+
+  private final int calcNumber = calcCount++;
+
+  private final AnnotationAction action;
+
+  private final List<ArgumentI> args;
+
+  private final Credentials credentials;
+
+  private TaskI<AnnotationResult> currentTask = null;
+
+  private TaskEventListener<AnnotationResult> taskListener = new TaskEventListener<>()
+  {
+    @Override
+    public void taskCompleted(TaskI source, AnnotationResult result)
+    {
+      int graphGroup = alignViewport.getAlignment().getLastGraphGroup();
+      List<AlignmentAnnotation> annotations = new ArrayList<>();
+      for (AlignmentAnnotation ala : result.getAnnotations())
+      {
+        if (ala.graphGroup > 0)
+          ala.graphGroup += graphGroup;
+        var newAnnot = alignViewport.getAlignment()
+            .updateFromOrCopyAnnotation(ala);
+        if (ala.sequenceRef != null)
+        {
+          ala.sequenceRef.addAlignmentAnnotation(newAnnot);
+          newAnnot.adjustForAlignment();
+          AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
+              newAnnot, newAnnot.label, newAnnot.getCalcId());
+        }
+        annotations.add(newAnnot);
+      }
+      updateOurAnnots(annotations);
+      listener.workerHasResult(
+          AlignCalcWorkerAdapter.this,
+          new AnnotationResult(
+              annotations,
+              result.transferFeatures,
+              result.featureColours,
+              result.featureFilters));
+    }
+  };
+
+  public AlignCalcWorkerAdapter(AlignViewportI alignViewport, AlignmentViewPanel alignPanel, AnnotationAction action,
+      List<ArgumentI> args, Credentials credentials)
+  {
+    super(alignViewport, alignPanel);
+    this.action = action;
+    this.args = args;
+    this.credentials = credentials;
+  }
+
+  @Override
+  public String getCalcName()
+  {
+    return action.getWebService().getName();
+  }
+
+  @Override
+  public void updateAnnotation()
+  {
+
+  }
+
+  private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
+  {
+    List<AlignmentAnnotation> oldAnnots = ourAnnots != null ? ourAnnots : List.of();
+    AlignmentI alignment = alignViewport.getAlignment();
+    for (AlignmentAnnotation annotation : oldAnnots)
+      if (!newAnnots.contains(annotation))
+        alignment.deleteAnnotation(annotation);
+    for (AlignmentAnnotation annotation : newAnnots)
+      alignment.validateAnnotation(annotation);
+    ourAnnots = Collections.synchronizedList(newAnnots);
+  }
+
+  @Override
+  public synchronized void startUp() throws Throwable
+  {
+    if (alignViewport.isClosed())
+    {
+      calcMan.disableWorker(this);
+      abortAndDestroy();
+      throw new IllegalStateException("Starting calculation for closed viewport");
+    }
+    currentTask = action.createTask(alignViewport, args, credentials);
+    currentTask.addTaskEventListener(taskListener);
+    currentTask.init();
+    listener.workerStarted(this);
+  }
+
+  @Override
+  public boolean poll() throws Throwable
+  {
+    return currentTask.poll();
+  }
+
+  @Override
+  public synchronized void cancel()
+  {
+    try
+    {
+      currentTask.cancel();
+    } finally
+    {
+      currentTask.removeTaskEventListener(taskListener);
+      listener.workerStopped(this);
+    }
+  }
+
+  @Override
+  public synchronized void done()
+  {
+    try
+    {
+      currentTask.complete();
+    } catch (Exception e)
+    {
+    } finally
+    {
+      currentTask.removeTaskEventListener(taskListener);
+      listener.workerStopped(this);
+    }
+  }
+
+  @Override
+  public boolean isDeletable()
+  {
+    return true;
+  }
+
+  @Override
+  public String toString()
+  {
+    return "AlignCalcWorkerAdapter-" + calcNumber + " for " + getCalcName();
+  }
+
+  public interface WorkerListener extends java.util.EventListener
+  {
+    default void workerStarted(AlignCalcWorkerAdapter source)
+    {
+    };
+
+    default void workerStopped(AlignCalcWorkerAdapter source)
+    {
+    };
+
+    default void workerHasResult(AlignCalcWorkerAdapter source, AnnotationResult result)
+    {
+    };
+
+    static final WorkerListener NULL_LISTENER = new WorkerListener()
+    {
+    };
+  }
+
+  private WorkerListener listener = WorkerListener.NULL_LISTENER;
+  
+  public void setWorkerListener(WorkerListener listener)
+  {
+    if (listener == null) listener = WorkerListener.NULL_LISTENER;
+    this.listener = listener;
+  }
+}
index 02829fd..b8450a9 100644 (file)
@@ -3,6 +3,7 @@ package jalview.ws2.actions.annotation;
 import java.util.List;
 import java.util.Objects;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.actions.BaseAction;
@@ -84,18 +85,14 @@ public class AnnotationAction extends BaseAction<AnnotationResult>
     requireAlignedSequences = builder.requireAlignedSequences;
     filterSymbols = builder.filterSymbols;
   }
-
+  
   @Override
-  public TaskI<AnnotationResult> perform(AlignmentViewport viewport,
-      List<ArgumentI> args, Credentials credentials,
-      TaskEventListener<AnnotationResult> handler)
+  public AnnotationTask createTask(AlignViewportI viewport,
+      List<ArgumentI> args, Credentials credentials)
   {
-    var task = new AnnotationTask(client, this, args, credentials, viewport,
-        handler);
-    task.start(viewport.getCalcManager());
-    return task;
+    return new AnnotationTask(client, this, args, credentials, viewport);
   }
-
+  
   /**
    * Return if this action is an alignment analysis service.
    */
index 3a836a0..d865352 100644 (file)
@@ -32,7 +32,8 @@ public interface AnnotationProviderI
    * @param job
    *          web service job
    * @param sequences
-   *          sequences the annotations will be added to
+   *          features and alignment annotation added to these will be
+   *          imported to the dataset for the alignment
    * @param colours
    *          container for feature colours
    * @param filters
index 271d9ed..866f862 100644 (file)
@@ -2,19 +2,13 @@ package jalview.ws2.actions.annotation;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 import jalview.analysis.AlignmentAnnotationUtils;
-import jalview.api.AlignCalcManagerI2;
-import jalview.api.AlignCalcWorkerI;
 import jalview.api.AlignViewportI;
 import jalview.api.FeatureColourI;
-import jalview.api.PollableAlignCalcWorkerI;
-import jalview.bin.Cache;
-import jalview.bin.Console;
 import jalview.datamodel.AlignmentAnnotation;
 import jalview.datamodel.AlignmentI;
 import jalview.datamodel.AnnotatedCollectionI;
@@ -23,306 +17,33 @@ import jalview.datamodel.ContiguousI;
 import jalview.datamodel.Mapping;
 import jalview.datamodel.SequenceI;
 import jalview.datamodel.features.FeatureMatcherSetI;
-import jalview.schemes.FeatureSettingsAdapter;
-import jalview.util.ArrayUtils;
 import jalview.util.MapList;
-import jalview.util.MathUtils;
-import jalview.util.Pair;
-import jalview.workers.AlignCalcWorker;
 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.JobI;
-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.api.WebServiceJobHandle;
 import jalview.ws2.client.api.AnnotationWebServiceClientI;
-import jalview.ws2.helpers.DelegateJobEventListener;
-import jalview.ws2.helpers.TaskEventSupport;
 
-public class AnnotationTask implements TaskI<AnnotationResult>
+public class AnnotationTask extends BaseTask<AnnotationJob, AnnotationResult>
 {
-  private final long uid = MathUtils.getUID();
-
   private AnnotationWebServiceClientI client;
 
   private final AnnotationAction action;
 
-  private final List<ArgumentI> args;
-
-  private final Credentials credentials;
-
-  private final AlignViewportI viewport;
-
-  private final TaskEventSupport<AnnotationResult> eventHandler;
-
-  private JobStatus taskStatus = null;
-
-  private AlignCalcWorkerAdapter worker = null;
-
-  private List<AnnotationJob> jobs = Collections.emptyList();
-
-  private AnnotationResult result = null;
-
-  private DelegateJobEventListener<AnnotationResult> jobEventHandler;
-
-  private class AlignCalcWorkerAdapter extends AlignCalcWorker
-      implements PollableAlignCalcWorkerI
-  {
-    private boolean restarting = false;
-
-    AlignCalcWorkerAdapter(AlignCalcManagerI2 calcMan)
-    {
-      super(viewport, null);
-      this.calcMan = calcMan;
-    }
-
-    String getServiceName()
-    {
-      return action.getWebService().getName();
-    }
-
-    @Override
-    public void startUp() throws Throwable
-    {
-      if (alignViewport.isClosed())
-      {
-        stop();
-        throw new IllegalStateException("Starting annotation for closed viewport");
-      }
-      if (restarting)
-        eventHandler.fireTaskRestarted();
-      else
-        restarting = true;
-      jobs = Collections.emptyList();
-      try
-      {
-        jobs = prepare();
-      } catch (ServiceInputInvalidException e)
-      {
-        setStatus(JobStatus.INVALID);
-        eventHandler.fireTaskException(e);
-        throw e;
-      }
-      setStatus(JobStatus.READY);
-      eventHandler.fireTaskStarted(jobs);
-      for (var job : jobs)
-      {
-        job.addPropertyChagneListener(jobEventHandler);
-      }
-      try
-      {
-        startJobs();
-      } catch (IOException e)
-      {
-        eventHandler.fireTaskException(e);
-        cancelJobs();
-        setStatus(JobStatus.SERVER_ERROR);
-        throw e;
-      }
-      setStatus(JobStatus.SUBMITTED);
-    }
-
-    @Override
-    public boolean poll() throws Throwable
-    {
-      boolean done = AnnotationTask.this.poll();
-      updateGlobalStatus();
-      if (done)
-      {
-        retrieveAndProcessResult();
-        eventHandler.fireTaskCompleted(result);
-      }
-      return done;
-    }
-
-    private void retrieveAndProcessResult() throws IOException
-    {
-      result = retrieveResult();
-      updateOurAnnots(result.annotations);
-      if (result.transferFeatures)
-      {
-        final var featureColours = result.featureColours;
-        final var featureFilters = result.featureFilters;
-        viewport.applyFeaturesStyle(new FeatureSettingsAdapter()
-        {
-          @Override
-          public FeatureColourI getFeatureColour(String type)
-          {
-            return featureColours.get(type);
-          }
-
-          @Override
-          public FeatureMatcherSetI getFeatureFilters(String type)
-          {
-            return featureFilters.get(type);
-          }
-
-          @Override
-          public boolean isFeatureDisplayed(String type)
-          {
-            return featureColours.containsKey(type);
-          }
-        });
-      }
-    }
-
-    @Override
-    public void updateAnnotation()
-    {
-      var job = jobs.size() > 0 ? jobs.get(0) : null;
-      if (!calcMan.isWorking(this) && job != null)
-      {
-        var ret = updateResultAnnotation(job, job.returnedAnnotations);
-        updateOurAnnots(ret.get0());
-      }
-    }
-
-    private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
-    {
-      List<AlignmentAnnotation> oldAnnots = ourAnnots != null ? ourAnnots : Collections.emptyList();
-      ourAnnots = newAnnots;
-      AlignmentI alignment = viewport.getAlignment();
-      for (AlignmentAnnotation an : oldAnnots)
-      {
-        if (!newAnnots.contains(an))
-        {
-          alignment.deleteAnnotation(an);
-        }
-      }
-      oldAnnots.clear();
-      for (AlignmentAnnotation an : ourAnnots)
-      {
-        viewport.getAlignment().validateAnnotation(an);
-      }
-    }
-
-    @Override
-    public void cancel()
-    {
-      cancelJobs();
-    }
-
-    void stop()
-    {
-      calcMan.disableWorker(this);
-      super.abortAndDestroy();
-    }
+  private final AlignmentI alignment;
 
-    @Override
-    public void done()
-    {
-      for (var job : jobs)
-      {
-        if (job.isInputValid() && !job.isCompleted())
-        {
-          /* if done was called but job is not completed then it
-           * must have been stopped by an exception */
-          job.setStatus(JobStatus.SERVER_ERROR);
-        }
-      }
-      updateGlobalStatus();
-      // dispose of unfinished jobs just in case
-      cancelJobs();
-    }
-
-    @Override
-    public String toString()
-    {
-      return AnnotationTask.this.toString() + "$AlignCalcWorker@"
-          + Integer.toHexString(hashCode());
-    }
-  }
+  private final AnnotatedCollectionI selectionGroup;
 
   public AnnotationTask(AnnotationWebServiceClientI client,
       AnnotationAction action, List<ArgumentI> args, Credentials credentials,
-      AlignViewportI viewport,
-      TaskEventListener<AnnotationResult> eventListener)
+      AlignViewportI viewport)
   {
+    super(client, args, credentials);
     this.client = client;
     this.action = action;
-    this.args = args;
-    this.credentials = credentials;
-    this.viewport = viewport;
-    this.eventHandler = new TaskEventSupport<>(this, eventListener);
-    this.jobEventHandler = new DelegateJobEventListener<>(this.eventHandler);
-  }
-
-  @Override
-  public long getUid()
-  {
-    return uid;
-  }
-
-  public void start(AlignCalcManagerI2 calcManager)
-  {
-    if (this.worker != null)
-      throw new IllegalStateException("task already started");
-    this.worker = new AlignCalcWorkerAdapter(calcManager);
-    if (taskStatus != JobStatus.CANCELLED)
-    {
-      List<AlignCalcWorkerI> oldWorkers = calcManager.getWorkersOfClass(
-          AlignCalcWorkerAdapter.class);
-      for (var worker : oldWorkers)
-      {
-        if (action.getWebService().getName().equalsIgnoreCase(
-            ((AlignCalcWorkerAdapter) worker).getServiceName()))
-        {
-          // remove interactive workers for the same service.
-          calcManager.removeWorker(worker);
-          calcManager.cancelWorker(worker);
-        }
-      }
-      if (action.getWebService().isInteractive())
-        calcManager.registerWorker(worker);
-      else
-        calcManager.startWorker(worker);
-    }
-  }
-
-  /*
-   * The following methods are mostly copied from the {@link AbstractPollableTask}
-   * TODO: move common functionality to a base class
-   */
-  @Override
-  public JobStatus getStatus()
-  {
-    return taskStatus;
-  }
-
-  private void setStatus(JobStatus status)
-  {
-    if (this.taskStatus != status)
-    {
-      Console.debug(String.format("%s status change to %s", this, status.name()));
-      this.taskStatus = status;
-      eventHandler.fireTaskStatusChanged(status);
-    }
-  }
-
-  private 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]);
-    }
-  }
-
-  @Override
-  public List<? extends JobI> getSubJobs()
-  {
-    return jobs;
+    this.alignment = viewport.getAlignment();
+    this.selectionGroup = viewport.getSelectionGroup();
   }
 
   /**
@@ -334,9 +55,9 @@ public class AnnotationTask implements TaskI<AnnotationResult>
    * @throws ServiceInputInvalidException
    *           input data is not valid
    */
-  private List<AnnotationJob> prepare() throws ServiceInputInvalidException
+  @Override
+  public List<AnnotationJob> prepareJobs() throws ServiceInputInvalidException
   {
-    AlignmentI alignment = viewport.getAlignment();
     if (alignment == null || alignment.getWidth() <= 0 ||
         alignment.getSequences() == null)
       throw new ServiceInputInvalidException("Alignment does not contain sequences");
@@ -347,7 +68,7 @@ public class AnnotationTask implements TaskI<AnnotationResult>
       throw new ServiceInputInvalidException(
           action.getFullName() + " does not allow protein sequences");
     boolean bySequence = !action.isAlignmentAnalysis();
-    AnnotatedCollectionI inputSeqs = bySequence ? viewport.getSelectionGroup() : null;
+    AnnotatedCollectionI inputSeqs = bySequence ? selectionGroup : null;
     if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
         inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
       inputSeqs = alignment;
@@ -366,38 +87,8 @@ public class AnnotationTask implements TaskI<AnnotationResult>
     return List.of(job);
   }
 
-  private void startJobs() throws IOException
-  {
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
-      {
-        var serverJob = client.submit(job.getInputSequences(),
-            args, credentials);
-        job.setServerJob(serverJob);
-        job.setStatus(JobStatus.SUBMITTED);
-      }
-    }
-  }
-
-  private boolean poll() throws IOException
-  {
-    boolean allDone = true;
-    for (BaseJob job : jobs)
-    {
-      if (job.isInputValid() && !job.getStatus().isDone())
-      {
-        WebServiceJobHandle serverJob = job.getServerJob();
-        job.setStatus(client.getStatus(serverJob));
-        job.setLog(client.getLog(serverJob));
-        job.setErrorLog(client.getErrorLog(serverJob));
-      }
-      allDone &= job.isCompleted();
-    }
-    return allDone;
-  }
-
-  private AnnotationResult retrieveResult() throws IOException
+  @Override
+  protected AnnotationResult collectResult(List<AnnotationJob> jobs) throws IOException
   {
     final Map<String, FeatureColourI> featureColours = new HashMap<>();
     final Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
@@ -410,179 +101,88 @@ public class AnnotationTask implements TaskI<AnnotationResult>
      * sequence, excluding regions not annotated due to gapMap/column
      * visibility */
 
-    // update calcId if it is not already set on returned annotation
-    for (AlignmentAnnotation annot : returnedAnnot)
-    {
-      if (annot.getCalcId() == null || annot.getCalcId().isEmpty())
-      {
-        annot.setCalcId(action.getFullName());
-      }
-      annot.autoCalculated = action.isAlignmentAnalysis() &&
-          action.getWebService().isInteractive();
-    }
-    job.returnedAnnotations = returnedAnnot;
-    job.featureColours = featureColours;
-    job.featureFilters = featureFilters;
-    var ret = updateResultAnnotation(job, returnedAnnot);
-    var annotations = ret.get0();
-    var transferFeatures = ret.get1();
-    return new AnnotationResult(annotations, transferFeatures, featureColours,
-        featureFilters);
-  }
-
-  private Pair<List<AlignmentAnnotation>, Boolean> updateResultAnnotation(
-      AnnotationJob job, List<AlignmentAnnotation> annotations)
-  {
-    List<AlignmentAnnotation> newAnnots = new ArrayList<>();
-    // update graphGroup for all annotation
-    /* find a graphGroup greater than any existing one, could be moved
-     * to Alignment#getNewGraphGroup() - returns next unused graph group */
-    int graphGroup = 1;
-    if (viewport.getAlignment().getAlignmentAnnotation() != null)
+    udpateCalcId(returnedAnnot);
+    for (AlignmentAnnotation ala : returnedAnnot)
     {
-      for (var ala : viewport.getAlignment().getAlignmentAnnotation())
-      {
-        graphGroup = Math.max(graphGroup, ala.graphGroup);
-      }
-    }
-    // update graphGroup in the annotation rows returned form service'
-    /* TODO: look at sequence annotation rows and update graph groups in the
-     * case of reference annotation */
-    for (AlignmentAnnotation ala : annotations)
-    {
-      if (ala.graphGroup > 0)
-        ala.graphGroup += graphGroup;
       SequenceI aseq = null;
-      // transfer sequence refs and adjust gapMap
       if (ala.sequenceRef != null)
       {
-        aseq = job.seqNames.get(ala.sequenceRef.getName());
+        SequenceI seq = job.seqNames.get(ala.sequenceRef.getName());
+        aseq = seq.getRootDatasetSequence();
       }
       ala.sequenceRef = aseq;
-
-      Annotation[] resAnnot = ala.annotations;
-      boolean[] gapMap = job.gapMap;
-      Annotation[] gappedAnnot = new Annotation[Math.max(
-          viewport.getAlignment().getWidth(), gapMap.length)];
-      for (int p = 0, ap = job.start; ap < gappedAnnot.length; ap++)
-      {
-        if (gapMap.length > ap && !gapMap[ap])
-          gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
-        else if (p < resAnnot.length)
-          gappedAnnot[ap] = resAnnot[p++];
-        // is this loop exhaustive of resAnnot?
-      }
-      ala.annotations = gappedAnnot;
-
-      AlignmentAnnotation newAnnot = viewport.getAlignment()
-          .updateFromOrCopyAnnotation(ala);
-      if (aseq != null)
-      {
-        aseq.addAlignmentAnnotation(newAnnot);
-        newAnnot.adjustForAlignment();
-        AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
-            newAnnot, newAnnot.label, newAnnot.getCalcId());
-      }
-      newAnnots.add(newAnnot);
+      Annotation[] gappedAnnots = createGappedAnnotations(ala.annotations, job.start, job.gapMap);
+      ala.annotations = gappedAnnots;
     }
 
-    boolean transferFeatures = false;
+    boolean hasFeatures = false;
     for (SequenceI sq : job.getInputSequences())
     {
-      if (!sq.getFeatures().hasFeatures() &&
-          (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
+      if (!sq.getFeatures().hasFeatures() && (sq.getDBRefs() == null || sq.getDBRefs().isEmpty()))
         continue;
-      transferFeatures = true;
+      hasFeatures = true;
       SequenceI seq = job.seqNames.get(sq.getName());
-      SequenceI dseq;
-      int start = job.start, end = job.end;
-      boolean[] gapMap = job.gapMap;
-      ContiguousI seqRange = seq.findPositions(start, end);
-      while ((dseq = seq).getDatasetSequence() != null)
-      {
-        seq = seq.getDatasetSequence();
-      }
-      List<ContiguousI> sourceRange = new ArrayList<>();
-      if (gapMap.length >= end)
-      {
-        int lastcol = start, col = start;
-        do
-        {
-          if (col == end || !gapMap[col])
-          {
-            if (lastcol <= (col - 1))
-            {
-              seqRange = seq.findPositions(lastcol, col);
-              sourceRange.add(seqRange);
-            }
-            lastcol = col + 1;
-          }
-        } while (col++ < end);
-      }
-      else
-      {
-        sourceRange.add(seq.findPositions(start, end));
-      }
-
-      int i = 0;
-      int sourceStartEnd[] = new int[sourceRange.size() * 2];
-      for (ContiguousI range : sourceRange)
-      {
-        sourceStartEnd[i++] = range.getBegin();
-        sourceStartEnd[i++] = range.getEnd();
-      }
+      SequenceI datasetSeq = seq.getRootDatasetSequence();
+      List<ContiguousI> sourceRange = findContiguousRanges(datasetSeq, job.gapMap, job.start, job.end);
+      int[] sourceStartEnd = ContiguousI.toStartEndArray(sourceRange);
       Mapping mp = new Mapping(new MapList(
           sourceStartEnd, new int[]
-          { seq.getStart(), seq.getEnd() }, 1, 1));
-      dseq.transferAnnotation(sq, mp);
+          { datasetSeq.getStart(), datasetSeq.getEnd() }, 1, 1));
+      datasetSeq.transferAnnotation(sq, mp);
     }
 
-    return new Pair<>(newAnnots, transferFeatures);
-  }
-
-  @Override
-  public AnnotationResult getResult()
-  {
-    return result;
+    return new AnnotationResult(returnedAnnot, hasFeatures, featureColours, featureFilters);
   }
 
-  @Override
-  public void cancel()
+  /**
+   * Updates calcId on provided annotations if not already set.
+   */
+  public void udpateCalcId(Iterable<AlignmentAnnotation> annotations)
   {
-    setStatus(JobStatus.CANCELLED);
-    if (worker != null)
+    for (var annotation : annotations)
     {
-      worker.stop();
+      if (annotation.getCalcId() == null || annotation.getCalcId().isEmpty())
+      {
+        annotation.setCalcId(action.getFullName());
+      }
+      annotation.autoCalculated = action.isAlignmentAnalysis() &&
+          action.getWebService().isInteractive();
     }
-    cancelJobs();
   }
 
-  public void cancelJobs()
+  private Annotation[] createGappedAnnotations(Annotation[] annotations, int start, boolean[] gapMap)
   {
-    for (BaseJob job : jobs)
+    var size = Math.max(alignment.getWidth(), gapMap.length);
+    Annotation[] gappedAnnotations = new Annotation[size];
+    for (int p = 0, ap = start; ap < size; ap++)
     {
-      if (!job.isCompleted())
+      if (gapMap != null && gapMap.length > ap && !gapMap[ap])
+      {
+        gappedAnnotations[ap] = new Annotation("", "", ' ', Float.NaN);
+      }
+      else if (p < annotations.length)
       {
-        try
-        {
-          if (job.getServerJob() != null)
-          {
-            client.cancel(job.getServerJob());
-          }
-          job.setStatus(JobStatus.CANCELLED);
-        } catch (IOException e)
-        {
-          Console.error(String.format(
-              "failed to cancel job %s", job.getServerJob()), e);
-        }
+        gappedAnnotations[ap] = annotations[p++];
       }
     }
+    return gappedAnnotations;
   }
 
-  @Override
-  public String toString()
+  private List<ContiguousI> findContiguousRanges(SequenceI seq, boolean[] gapMap, int start, int end)
   {
-    var status = taskStatus != null ? taskStatus.name() : "UNSET";
-    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
+    if (gapMap == null || gapMap.length < end)
+      return List.of(seq.findPositions(start, end));
+    List<ContiguousI> ranges = new ArrayList<>();
+    int lastcol = start, col = start;
+    do
+    {
+      if (col == end || !gapMap[col])
+      {
+        if (lastcol < col)
+          ranges.add(seq.findPositions(lastcol, col));
+        lastcol = col + 1;
+      }
+    } while (++col <= end);
+    return ranges;
   }
 }
index 52d70df..e84fd58 100644 (file)
@@ -5,6 +5,7 @@ import java.util.List;
 
 import javax.swing.Icon;
 
+import jalview.api.AlignViewportI;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ArgumentI;
 import jalview.ws2.api.CredentialType;
@@ -115,10 +116,8 @@ public interface ActionI<R>
   EnumSet<CredentialType> getRequiredCredentials();
 
   /**
-   * Run the action, create and start a new task with provided viewport,
-   * arguments and credentials and attach the handler to the task. The
-   * implementations of this method are responsible for starting the task using
-   * execution method appropriate for the action class.
+   * Create a new task with provided viewport, arguments and credentials ready
+   * to be started.
    * 
    * @param viewport
    *          current alignment viewport
@@ -126,12 +125,10 @@ public interface ActionI<R>
    *          job parameters appropriate for the service
    * @param credentials
    *          optional user credentials
-   * @param handler
-   *          event handler attached to the new task
-   * @return new running task
+   * @return new task
    */
-  TaskI<R> perform(AlignmentViewport viewport, List<ArgumentI> args,
-      Credentials credentials, TaskEventListener<R> handler);
+  TaskI<R> createTask(AlignViewportI viewport, List<ArgumentI> args,
+      Credentials credentials);
 
   /**
    * Return if the action is currently active for the given viewport. Active
index 94de9d0..b0bb4b6 100644 (file)
@@ -2,6 +2,7 @@ package jalview.ws2.actions.api;
 
 import java.util.List;
 
+import jalview.bin.Console;
 import jalview.ws2.api.JobStatus;
 import jalview.ws2.api.WebServiceJobHandle;
 
@@ -29,7 +30,7 @@ public interface TaskEventListener<T>
    * @param subJobs
    *          list of sub-jobs for this run
    */
-  void taskStarted(TaskI<T> source, List<? extends JobI> subJobs);
+  default void taskStarted(TaskI<T> source, List<? extends JobI> subJobs) {};
 
   /**
    * Invoked when the global task status has changed.
@@ -39,7 +40,7 @@ public interface TaskEventListener<T>
    * @param status
    *          new task status
    */
-  void taskStatusChanged(TaskI<T> source, JobStatus status);
+  default void taskStatusChanged(TaskI<T> source, JobStatus status) {};
 
   /**
    * Invoked when the task has completed. If the task completed with a result,
@@ -51,7 +52,7 @@ public interface TaskEventListener<T>
    * @param result
    *          computation result or null if result not present
    */
-  void taskCompleted(TaskI<T> source, T result);
+  default void taskCompleted(TaskI<T> source, T result) {};
 
   /**
    * Invoked when an unhandled exception has occurred during task execution.
@@ -61,17 +62,7 @@ public interface TaskEventListener<T>
    * @param e
    *          exception
    */
-  void taskException(TaskI<T> source, Exception e);
-
-  /**
-   * Invoked when the task had been restarted. This event is only applicable to
-   * restartable tasks and will precede each {@link #taskStarted} after the
-   * first one.
-   * 
-   * @param source
-   *          task this event originates from
-   */
-  void taskRestarted(TaskI<T> source);
+  default void taskException(TaskI<T> source, Exception e) {};
 
   /**
    * Invoked when the status of a sub-job has changed.
@@ -83,7 +74,7 @@ public interface TaskEventListener<T>
    * @param status
    *          new job status
    */
-  void subJobStatusChanged(TaskI<T> source, JobI job, JobStatus status);
+  default void subJobStatusChanged(TaskI<T> source, JobI job, JobStatus status) {};
 
   /**
    * Invoked when a log string of the sub-job has changed.
@@ -95,7 +86,7 @@ public interface TaskEventListener<T>
    * @param log
    *          new log string
    */
-  void subJobLogChanged(TaskI<T> source, JobI job, String log);
+  default void subJobLogChanged(TaskI<T> source, JobI job, String log) {};
 
   /**
    * Invoked when an error log string of the sub-job has changed.
@@ -107,5 +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 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 cb84944..5f4d575 100644 (file)
@@ -38,6 +38,10 @@ public interface TaskI<T>
    */
   List<? extends JobI> getSubJobs();
 
+  void addTaskEventListener(TaskEventListener<T> listener);
+
+  void removeTaskEventListener(TaskEventListener<T> listener);
+
   /**
    * Get the last result of the task or {@code null} if not present. Note that
    * the result is subject to change for restartable tasks.
@@ -46,6 +50,12 @@ public interface TaskI<T>
    */
   T getResult();
 
+  public void init() throws Exception;
+
+  public boolean poll() throws Exception;
+
+  public void complete() throws Exception;
+
   /**
    * Cancel the task, stop all sub-jobs running on a server and stop all threads
    * managing this task.
diff --git a/src/jalview/ws2/actions/hmmer/PhmmerAction.java b/src/jalview/ws2/actions/hmmer/PhmmerAction.java
new file mode 100644 (file)
index 0000000..bdba2f7
--- /dev/null
@@ -0,0 +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;
+  }
+
+  public TaskI<AlignmentI> perform(AlignmentViewport viewport,
+          List<ArgumentI> args, Credentials credentials,
+          TaskEventListener<AlignmentI> handler)
+  {
+    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;
+  }
+}
diff --git a/src/jalview/ws2/actions/hmmer/PhmmerTask.java b/src/jalview/ws2/actions/hmmer/PhmmerTask.java
new file mode 100644 (file)
index 0000000..ede61d3
--- /dev/null
@@ -0,0 +1,111 @@
+package jalview.ws2.actions.hmmer;
+
+import static jalview.util.Comparison.GapChars;
+
+import java.io.IOException;
+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.BaseJob;
+import jalview.ws2.actions.BaseTask;
+import jalview.ws2.actions.ServiceInputInvalidException;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+
+class PhmmerTask extends BaseTask<BaseJob, AlignmentI>
+{
+  private final AlignmentWebServiceClientI client;
+  private final AlignmentView view;
+
+  PhmmerTask(AlignmentWebServiceClientI client, List<ArgumentI> args,
+          Credentials credentials, AlignmentView view)
+  {
+    super(client, args, credentials);
+    this.client = client;
+    this.view = view;
+  }
+
+  @Override
+  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 collectResult(List<BaseJob> jobs) throws IOException
+  {
+    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 cc7c714..5e482ab 100644 (file)
@@ -5,19 +5,39 @@ import java.util.Objects;
 public final class Credentials
 {
   String username = null;
+
   String email = null;
+
   String password = null;
+
   private static final Credentials EMPTY = new Credentials();
 
-  private Credentials() {
+  private Credentials()
+  {
+  }
+
+  public String getUsername()
+  {
+    return username;
+  }
+
+  public String getEmail()
+  {
+    return email;
   }
-  
+
+  public String getPassword()
+  {
+    return password;
+  }
+
   public static final Credentials empty()
   {
     return EMPTY;
   }
 
-  public static final Credentials usingEmail(String email) {
+  public static final Credentials usingEmail(String email)
+  {
     Objects.requireNonNull(email);
     if (email.isEmpty())
       throw new IllegalArgumentException("empty email");
@@ -25,8 +45,9 @@ public final class Credentials
     credentials.email = email;
     return credentials;
   }
-  
-  public static final Credentials usingEmail(String email, String password) {
+
+  public static final Credentials usingEmail(String email, String password)
+  {
     Objects.requireNonNull(email);
     Objects.requireNonNull(password);
     if (email.isEmpty())
@@ -36,8 +57,10 @@ public final class Credentials
     credentials.password = password;
     return credentials;
   }
-  
-  public static final Credentials usingUsername(String username, String password) {
+
+  public static final Credentials usingUsername(String username,
+          String password)
+  {
     Objects.requireNonNull(username);
     Objects.requireNonNull(password);
     if (username.isEmpty())
diff --git a/src/jalview/ws2/client/ebi/JobDispatcherWSDiscoverer.java b/src/jalview/ws2/client/ebi/JobDispatcherWSDiscoverer.java
new file mode 100644 (file)
index 0000000..48d0ba5
--- /dev/null
@@ -0,0 +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);
+    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);
+  }
+
+}
diff --git a/src/jalview/ws2/client/ebi/ParamStores.java b/src/jalview/ws2/client/ebi/ParamStores.java
new file mode 100644 (file)
index 0000000..ffd338d
--- /dev/null
@@ -0,0 +1,285 @@
+package jalview.ws2.client.ebi;
+
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws.params.ParamManager;
+import jalview.ws.params.simple.*;
+import jalview.ws2.params.SimpleParamDatastore;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+class ParamStores
+{
+  static final List<ArgumentI> phmmerParameters;
+
+  /**
+   * Set up phmmer parameters list.
+   */
+  static
+  {
+    var parameters = new ArrayList<ArgumentI>();
+    URL baseURL;
+    try
+    {
+      baseURL = new URL( "https://www.ebi.ac.uk/Tools/services/rest/hmmer3_phmmer/");
+    } catch (MalformedURLException e)
+    {
+      throw new ExceptionInInitializerError(e);
+    }
+    {
+      var builder = StringParameter.newBuilder();
+      builder.setName("cut-offs");
+      builder.setLabel("Cut-offs");
+      builder.setDescription("Set the method of controlling which target sequences match your query.");
+      builder.setRequired(true);
+      builder.setValue("E");
+      builder.setDefaultValue("E");
+      builder.setPossibleValues(List.of("E", "T"));
+      builder.setDisplayValues(List.of("E-values", "Bit scores"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("incE");
+      builder.setLabel("Significance E-values[Sequence]");
+      builder.setDescription("Significance E-values[Sequence]");
+      builder.setRequired(false);
+      builder.setValue(0.01);
+      builder.setDefaultValue(0.01);
+      builder.setMin(0.);
+      builder.setMax(10.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/incE"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("incdomE");
+      builder.setLabel("Significance E-values[Hit]");
+      builder.setDescription("Significance E-values[Hit]");
+      builder.setRequired(false);
+      builder.setValue(0.03);
+      builder.setDefaultValue(0.03);
+      builder.setMin(0.);
+      builder.setMax(10.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/incdomE"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("E");
+      builder.setLabel("Report E-values[Sequence]");
+      builder.setDescription("Report E-values[Sequence]");
+      builder.setRequired(false);
+      builder.setValue(1.0);
+      builder.setDefaultValue(1.0);
+      builder.setMin(0.);
+      builder.setMax(10.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/E"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("domE");
+      builder.setLabel("Report E-values[Hit]");
+      builder.setDescription("Report E-values[Hit]");
+      builder.setRequired(false);
+      builder.setValue(1.0);
+      builder.setDefaultValue(1.0);
+      builder.setMin(0.);
+      builder.setMax(10.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/domE"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("incT");
+      builder.setLabel("Significance bit scores[Sequence]");
+      builder.setDescription("Significance bit scores[Sequence]");
+      builder.setRequired(false);
+      builder.setValue(25.0);
+      builder.setDefaultValue(25.0);
+      builder.setMin(0.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/incT"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("incdomT");
+      builder.setLabel("Significance bit scores[Hit]");
+      builder.setDescription("Significance bit scores[Hit]");
+      builder.setRequired(false);
+      builder.setValue(22.0);
+      builder.setDefaultValue(22.0);
+      builder.setMin(0.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/incdomT"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("T");
+      builder.setLabel("Report bit scores[Sequence]");
+      builder.setDescription("Report bit scores[Sequence]");
+      builder.setRequired(false);
+      builder.setValue(7.0);
+      builder.setDefaultValue(7.0);
+      builder.setMin(0.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/T"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("domT");
+      builder.setLabel("Report bit scores[Hit]");
+      builder.setDescription("Report bit scores[Hit]");
+      builder.setRequired(false);
+      builder.setValue(5.0);
+      builder.setDefaultValue(5.0);
+      builder.setMin(0.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/domT"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("popen");
+      builder.setLabel("Gap Penalties[open]");
+      builder.setDescription("Gap Penalties[open]");
+      builder.setRequired(false);
+      builder.setValue(0.02);
+      builder.setDefaultValue(0.02);
+      builder.setMin(0.);
+      builder.setMax(0.5);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/popen"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("pextend");
+      builder.setLabel("Gap Penalties[extend]");
+      builder.setDescription("Gap Penalties[extend]");
+      builder.setRequired(false);
+      builder.setValue(0.4);
+      builder.setDefaultValue(0.4);
+      builder.setMin(0.);
+      builder.setMax(1.);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/pextend"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = StringParameter.newBuilder();
+      builder.setName("mx");
+      builder.setLabel("Gap Penalties[Substitution scoring matrix]");
+      builder.setDescription("Gap Penalties[Substitution scoring matrix]");
+      builder.setRequired(false);
+      builder.setValue("BLOSUM62");
+      builder.setDefaultValue("BLOSUM62");
+      builder.setPossibleValues(List.of("BLOSUM45", "BLOSUM62", "BLOSUM90", "PAM30", "PAM70"));
+      builder.setDisplayValues(List.of("BLOSUM45", "BLOSUM62", "BLOSUM90", "PAM30", "PAM70"));
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/mx"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = BooleanOption.newBuilder();
+      builder.setName("nobias");
+      builder.setLabel("No bias filter");
+      builder.setDescription(
+              "The '--nobias' option turns off (bypasses) the biased composition filter which is on by default.");
+      builder.setRequired(false);
+      builder.setValue(false);
+      builder.setDefaultValue(false);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/nobias"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = BooleanOption.newBuilder();
+      builder.setName("compressedout");
+      builder.setLabel("Compressed Output");
+      builder.setDescription(
+              "By default it runs hmm2c plus post-processing (default output), whereas with compressedout, it gets compressed output only.");
+      builder.setRequired(false);
+      builder.setValue(false);
+      builder.setDefaultValue(false);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/compressedout"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = BooleanOption.newBuilder();
+      builder.setName("alignView");
+      builder.setLabel("Output Alignment");
+      builder.setDescription("Output alignment in result");
+      builder.setRequired(false);
+      builder.setValue(true);
+      builder.setDefaultValue(true);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/alignView"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = StringParameter.newBuilder();
+      builder.setName("database");
+      builder.setLabel("Sequence Database");
+      builder.setDescription("Sequence Database Selection");
+      builder.setRequired(true);
+      builder.setValue("uniprotkb");
+      builder.setDefaultValue("uniprotkb");
+      builder.setPossibleValues(List.of("swissprot", "uniprotrefprot", "uniprotkb", "pdb", "rp75", "rp55", "rp35", "rp15", "ensembl", "merops", "qfo", "chembl"));
+      builder.setDisplayValues(List.of("SwissProt", "Reference Proteomes", "UniProtKB", "PDB", "rp75", "rp55", "rp35", "rp15", "Ensembl", "MEROPS", "Quest for Orthologs", "ChEMBL"));
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/database"));
+      parameters.add(builder.build());
+    }
+    {
+      var builder = DoubleParameter.newBuilder();
+      builder.setName("evalue");
+      builder.setLabel("Expectation Value Threshold");
+      builder.setDescription(
+              "Expectation value cut-off for reporting target profiles in the per-target output.");
+      builder.setRequired(false);
+      builder.setValue(0.01);
+      builder.setDefaultValue(0.01);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/evalue"));
+      parameters.add(builder.build());
+    }
+//    {
+//      var builder = StringParameter.newBuilder();
+//      builder.setName("sequence");
+//      builder.setLabel("Input Sequence");
+//      builder.setDescription(
+//              "The input sequence can be entered directly into this form. The sequence can be be in FASTA or UniProtKB/Swiss-Prot format. A partially formatted sequence is not accepted. Adding a return to the end of the sequence may help certain applications understand the input. Note that directly using data from word processors may yield unpredictable results as hidden/control characters may be present.");
+//      builder.setRequired(false);
+//
+//      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/sequence"));
+//      parameters.add(builder.build());
+//    }
+    {
+      var builder = IntegerParameter.newBuilder();
+      builder.setName("nhits");
+      builder.setLabel("Number of Hits Displayed");
+      builder.setDescription("Number of hits to be displayed.");
+      builder.setRequired(false);
+      builder.setValue(100);
+      builder.setDefaultValue(100);
+      builder.setDetailsUrl(resolveURL(baseURL, "parameterdetails/nhits"));
+      parameters.add(builder.build());
+    }
+    phmmerParameters = Collections.unmodifiableList(parameters);
+  }
+
+  private static URL resolveURL(URL base, String spec)
+  {
+    try
+    {
+      return new URL(base, spec);
+    } catch (MalformedURLException e)
+    {
+      throw new ExceptionInInitializerError(e);
+    }
+  }
+  
+  public static ParamDatastoreI newPhmmerDatastore(URL url, ParamManager manager)
+  {
+    return new SimpleParamDatastore(url, phmmerParameters,
+            Collections.emptyList(), manager);
+  }
+}
diff --git a/src/jalview/ws2/client/ebi/PhmmerWSClient.java b/src/jalview/ws2/client/ebi/PhmmerWSClient.java
new file mode 100644 (file)
index 0000000..380e8e6
--- /dev/null
@@ -0,0 +1,271 @@
+package jalview.ws2.client.ebi;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringReader;
+import java.net.URI;
+import java.util.List;
+
+import jalview.bin.Console;
+import jalview.datamodel.Alignment;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.SequenceI;
+import jalview.io.DataSourceType;
+import jalview.io.FileFormat;
+import jalview.io.FileParse;
+import jalview.io.FormatAdapter;
+import jalview.io.StockholmFile;
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.simple.BooleanOption;
+import jalview.ws.params.simple.DoubleParameter;
+import jalview.ws.params.simple.IntegerParameter;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+import jalview.ws2.client.api.WebServiceClientI;
+import uk.ac.dundee.compbio.hmmerclient.PhmmerClient;
+import uk.ac.dundee.compbio.hmmerclient.PhmmerRequest;
+import uk.ac.dundee.compbio.hmmerclient.PhmmerRequest.SequenceDatabase;
+import uk.ac.dundee.compbio.hmmerclient.PhmmerRequest.SubstitutionMatrix;
+
+public class PhmmerWSClient implements AlignmentWebServiceClientI
+{
+
+  final PhmmerClient client;
+
+  PhmmerWSClient(PhmmerClient client)
+  {
+    this.client = client;
+  }
+
+  @Override
+  public String getUrl()
+  {
+    return client.getURL().toString();
+  }
+
+  @Override
+  public String getClientName()
+  {
+    return "ebi-job-dispatcher";
+  }
+
+  @Override
+  public WebServiceJobHandle submit(List<SequenceI> sequences,
+          List<ArgumentI> args, Credentials credentials) throws IOException
+  {
+    var request = PhmmerRequest.newBuilder();
+    String sequence = FileFormat.Fasta.getWriter(null)
+            .print(new SequenceI[]{ sequences.get(0) }, false);
+    request.sequence(new StringReader(sequence));
+    populateRequestArguments(request, args);
+    var email = credentials.getEmail() != null ? credentials.getEmail() :
+      "nouser@jalview.org";
+    var jobId = client.submit(request.build(), email);
+    Console.debug("Phmmer client submitted new job with id " + jobId);
+    return new WebServiceJobHandle(
+            getClientName(), "phmmer", getUrl(), jobId);
+  }
+  
+  private static void populateRequestArguments(PhmmerRequest.Builder request, List<ArgumentI> args)
+  {
+    boolean useBitScore = false;
+    boolean useEValue = false;
+    for (var arg : args)
+    {
+      if (arg.getName().equals("cut-offs"))
+        if (arg.getValue().equals("E"))
+          useEValue = true;
+        else if (arg.getValue().equals("T"))
+          useBitScore = true;
+        else
+          throw new IllegalArgumentException(
+                  "cut-offs argument contains value other than \"E\" or \"T\": "
+                          + arg.getValue());
+    }
+    assert (useBitScore || useEValue) && !(useBitScore && useEValue);
+    for (var arg : args)
+    {
+      switch (arg.getName())
+      {
+      case "incE":
+        request.incE(useEValue ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "incdomE":
+        request.incdomE(useEValue ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "E":
+        request.E(useEValue ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "domE":
+        request.domE(useEValue ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "incT":
+        request.incT(useBitScore ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "incdomT":
+        request.incdomT(useBitScore ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "T":
+        request.T(useBitScore ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "domT":
+        request.domT(useBitScore ? DoubleParameter.parseFloat(arg) : null);
+        break;
+      case "popen":
+        request.popen(DoubleParameter.parseFloat(arg));
+        break;
+      case "pextend":
+        request.pextend(DoubleParameter.parseFloat(arg));
+        break;
+      case "mx":
+        request.mx(parseSubstitutionMatrix(arg));
+        break;
+      case "nobias":
+        request.noBias(BooleanOption.parseBoolean(arg));
+        break;
+      case "compressedout":
+        request.compressedOut(BooleanOption.parseBoolean(arg));
+        break;
+      case "alignView":
+        request.compressedOut(BooleanOption.parseBoolean(arg));
+        break;
+      case "database":
+        request.database(parseSequenceDatabase(arg));
+        break;
+      case "evalue":
+        request.evalue(DoubleParameter.parseFloat(arg));
+        break;
+      case "nhits":
+        request.nhits(IntegerParameter.parseInt(arg));
+        break;
+      }
+    }
+  }
+
+  private static SubstitutionMatrix parseSubstitutionMatrix(ArgumentI arg)
+  {
+    if (arg.getValue() == null)
+      return null;
+    switch (arg.getValue())
+    {
+    case "BLOSUM45":
+      return SubstitutionMatrix.BLOSUM45;
+    case "BLOSUM62":
+      return SubstitutionMatrix.BLOSUM62;
+    case "BLOSUM90":
+      return SubstitutionMatrix.BLOSUM90;
+    case "PAM30":
+      return SubstitutionMatrix.PAM30;
+    case "PAM70":
+      return SubstitutionMatrix.PAM70;
+    default:
+      throw new IllegalArgumentException(
+              "invalid matrix " + arg.getValue());
+    }
+  }
+
+  private static SequenceDatabase parseSequenceDatabase(ArgumentI arg)
+  {
+    if (arg.getValue() == null)
+      return null;
+    switch (arg.getValue())
+    {
+    case "swissprot":
+      return SequenceDatabase.SWISS_PROT;
+    case "uniprotrefprot":
+      return SequenceDatabase.REFERENCE_PROTEOMES;
+    case "uniprotkb":
+      return SequenceDatabase.UNIPROTKB;
+    case "pdb":
+      return SequenceDatabase.PDB;
+    case "rp75":
+      return SequenceDatabase.RP75;
+    case "rp55":
+      return SequenceDatabase.RP55;
+    case "rp35":
+      return SequenceDatabase.RP35;
+    case "rp15":
+      return SequenceDatabase.RP15;
+    case "ensembl":
+      return SequenceDatabase.ENSEMBL;
+    case "merops":
+      return SequenceDatabase.MEROPS;
+    case "qfo":
+      return SequenceDatabase.QUEST_FOR_ORTHOLOGS;
+    case "chembl":
+      return SequenceDatabase.CHEMBL;
+    default:
+      throw new IllegalArgumentException(
+              "invalid database " + arg.getValue());
+    }
+  }
+
+  @Override
+  public JobStatus getStatus(WebServiceJobHandle job) throws IOException
+  {
+    var status = client.getStatus(job.getJobId());
+    switch (status)
+    {
+    case PENDING: return JobStatus.SUBMITTED;
+    case QUEUED: return JobStatus.QUEUED;
+    case RUNNING: return JobStatus.RUNNING;
+    case FINISHED: return JobStatus.COMPLETED;
+    case FAILURE: return JobStatus.FAILED;
+    case ERROR: return JobStatus.SERVER_ERROR;
+    case NOT_FOUND: return JobStatus.SERVER_ERROR;
+    case UNDEFINED: return JobStatus.UNKNOWN;
+    }
+    return JobStatus.UNKNOWN;
+  }
+
+  @Override
+  public String getLog(WebServiceJobHandle job) throws IOException
+  {
+    return "";
+  }
+
+  @Override
+  public String getErrorLog(WebServiceJobHandle job) throws IOException
+  {
+    if (getStatus(job) != JobStatus.FAILED)
+      return "";
+    try(InputStream stream = client.getResultStream(job.getJobId(), "error"))
+    {
+      ByteArrayOutputStream out = new ByteArrayOutputStream();
+      stream.transferTo(out);
+      return out.toString();
+    }
+  }
+
+  @Override
+  public void cancel(WebServiceJobHandle job)
+          throws IOException, UnsupportedOperationException
+  {
+    throw new UnsupportedOperationException(
+            "ebi job dispatcher does not support job cancellation");
+  }
+
+  /**
+   * FIXME: Temporary hack
+   */
+  @Override
+  public AlignmentI getAlignment(WebServiceJobHandle job) throws IOException
+  {
+    URI url = client.getResultURL(job.getJobId(), "sto");
+    try(InputStream stream = client.getResultStream(job.getJobId(), "sto"))
+    {
+      StockholmFile file = new StockholmFile(new FileParse(
+              new BufferedReader(new InputStreamReader(stream)),
+              url.toString(), DataSourceType.URL));
+      var aln = new Alignment(file.getSeqsAsArray());
+      for (var annotation : file.getAnnotations())
+        aln.addAnnotation(annotation);
+      return aln;
+    }
+  }
+}
index 7dcdae1..477bc34 100644 (file)
@@ -272,7 +272,8 @@ class SlivkaAnnotationWSClient extends SlivkaWSClient
       {
         FeaturesFile ff = new FeaturesFile(f.getContentUrl().toString(),
             DataSourceType.URL);
-        featPresent = ff.parse(aln, colours, true);
+        // TODO: determine if relaxed id matching is T/F
+        featPresent = ff.parse(aln, colours, filters, false, true);
         if (featPresent)
           Console.debug(format("loaded features for %s", service.getId()));
       }
index fd1d0ad..caa0573 100644 (file)
@@ -230,12 +230,6 @@ class AlignmentServiceGuiHandler
   }
 
   @Override
-  public void taskRestarted(TaskI<AlignmentResult> source)
-  {
-    // alignment services are not restartable
-  }
-
-  @Override
   public void subJobStatusChanged(TaskI<AlignmentResult> source, JobI job, JobStatus status)
   {
     int i = ArrayUtils.indexOf(jobs, job);
index 43e2680..a7202e9 100644 (file)
@@ -1,21 +1,22 @@
 package jalview.ws2.gui;
 
-import java.util.List;
-
+import jalview.api.FeatureColourI;
+import jalview.datamodel.features.FeatureMatcherSetI;
 import jalview.gui.AlignFrame;
 import jalview.gui.AlignmentPanel;
 import jalview.gui.IProgressIndicator;
 import jalview.gui.IProgressIndicatorHandler;
+import jalview.schemes.FeatureSettingsAdapter;
+import jalview.util.MathUtils;
+import jalview.ws2.actions.annotation.AlignCalcWorkerAdapter;
 import jalview.ws2.actions.annotation.AnnotationAction;
 import jalview.ws2.actions.annotation.AnnotationResult;
-import jalview.ws2.actions.api.JobI;
-import jalview.ws2.actions.api.TaskEventListener;
-import jalview.ws2.actions.api.TaskI;
-import jalview.ws2.api.JobStatus;
 
 public class AnnotationServiceGuiHandler
-    implements TaskEventListener<AnnotationResult>
+    implements AlignCalcWorkerAdapter.WorkerListener
 {
+  private final long progressId = MathUtils.getUID();
+
   private final AlignFrame alignFrame;
 
   private final AlignmentPanel alignPanel;
@@ -33,9 +34,10 @@ public class AnnotationServiceGuiHandler
   }
 
   @Override
-  public void taskStarted(TaskI<AnnotationResult> source, List<? extends JobI> subJobs)
+  public void workerStarted(AlignCalcWorkerAdapter source)
   {
-    progressIndicator.registerHandler(source.getUid(),
+    progressIndicator.addProgressBar(progressId, action.getFullName());
+    progressIndicator.registerHandler(progressId,
         new IProgressIndicatorHandler()
         {
           @Override
@@ -54,67 +56,44 @@ public class AnnotationServiceGuiHandler
   }
 
   @Override
-  public void taskStatusChanged(TaskI<AnnotationResult> source, JobStatus status)
+  public void workerStopped(AlignCalcWorkerAdapter source)
   {
-    switch (status)
-    {
-    case INVALID:
-    case COMPLETED:
-    case CANCELLED:
-    case FAILED:
-    case SERVER_ERROR:
-      progressIndicator.removeProgressBar(source.getUid());
-      break;
-    case READY:
-    case SUBMITTED:
-    case QUEUED:
-    case RUNNING:
-    case UNKNOWN:
-      progressIndicator.addProgressBar(source.getUid(), action.getFullName());
-      break;
-    }
+    progressIndicator.removeProgressBar(progressId);
   }
 
   @Override
-  public void taskCompleted(TaskI<AnnotationResult> source, AnnotationResult result)
+  public void workerHasResult(AlignCalcWorkerAdapter source, final AnnotationResult result)
   {
     if (result == null)
       return;
-    if (result.getTransferFeatures() && alignFrame.alignPanel == alignPanel)
+    if (result.getTransferFeatures())
     {
-      alignFrame.getViewport().setShowSequenceFeatures(true);
-      alignFrame.setMenusForViewport();
-    }
-    alignPanel.adjustAnnotationHeight();
-  }
-
-  @Override
-  public void taskException(TaskI<AnnotationResult> source, Exception e)
-  {
-
-  }
-
-  @Override
-  public void taskRestarted(TaskI<AnnotationResult> source)
-  {
-
-  }
-
-  @Override
-  public void subJobStatusChanged(TaskI<AnnotationResult> source, JobI job, JobStatus status)
-  {
-
-  }
-
-  @Override
-  public void subJobLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
-  {
-
-  }
+      alignFrame.getViewport().applyFeaturesStyle(new FeatureSettingsAdapter()
+      {
+        @Override
+        public FeatureColourI getFeatureColour(String type)
+        {
+          return result.getFeatureColours().get(type);
+        }
 
-  @Override
-  public void subJobErrorLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
-  {
+        @Override
+        public FeatureMatcherSetI getFeatureFilters(String type)
+        {
+          return result.getFeatureFilters().get(type);
+        }
 
+        @Override
+        public boolean isFeatureDisplayed(String type)
+        {
+          return result.getFeatureColours().containsKey(type);
+        }
+      });
+      if (alignFrame.alignPanel == alignPanel)
+      {
+        alignFrame.getViewport().setShowSequenceFeatures(true);
+        alignFrame.setMenusForViewport();
+      }
+    }
+    alignPanel.adjustAnnotationHeight();
   }
 }
diff --git a/src/jalview/ws2/gui/SearchServiceGuiHandler.java b/src/jalview/ws2/gui/SearchServiceGuiHandler.java
new file mode 100644 (file)
index 0000000..49df466
--- /dev/null
@@ -0,0 +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 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]));
+  }
+}
index d6cf5b7..3757199 100644 (file)
@@ -3,17 +3,14 @@ package jalview.ws2.gui;
 import java.awt.Color;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
-import java.lang.ref.WeakReference;
 import java.net.URL;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.TreeMap;
 import java.util.concurrent.CompletionStage;
 
@@ -24,6 +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;
@@ -33,16 +32,20 @@ import jalview.viewmodel.AlignmentViewport;
 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;
 
-import static java.lang.String.format;
-
 public class WebServicesMenuManager
 {
   private final JMenu menu;
@@ -58,8 +61,6 @@ public class WebServicesMenuManager
     noServicesItem.setEnabled(false);
   }
 
-  private Map<String, WeakReference<TaskI<?>>> interactiveTasks = new HashMap<>();
-
   public WebServicesMenuManager(String name, AlignFrame frame)
   {
     this.frame = frame;
@@ -164,7 +165,8 @@ public class WebServicesMenuManager
         // sort actions by name pulling nulls to the front
         actions.sort(Comparator.comparing(
             ActionI::getName, Comparator.nullsFirst(Comparator.naturalOrder())));
-        for (int i = 0; i < actions.size(); i++) {
+        for (int i = 0; i < actions.size(); i++)
+        {
           addEntriesForAction(actions.get(i), atMenu, atMenu == menu);
         }
       }
@@ -367,15 +369,8 @@ public class WebServicesMenuManager
         serviceItem.removeActionListener(l);
       WebService<?> service = action.getWebService();
       serviceItem.addActionListener(e -> {
-        if (serviceItem.getState())
-        {
-          cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
-              Credentials.empty());
-        }
-        else
-        {
-          cancelInteractive(service.getName());
-        }
+        runAction(action, frame.getCurrentView(), arguments,
+            Credentials.empty());
       });
       serviceItem.setSelected(true);
 
@@ -394,7 +389,7 @@ public class WebServicesMenuManager
                   lastPreset[0] = null;
                   arguments.clear();
                   arguments.addAll(args);
-                  cancelAndRunInteractive(action, frame.getCurrentView(),
+                  runAction(action, frame.getCurrentView(),
                       arguments, Credentials.empty());
                 }
               });
@@ -411,14 +406,14 @@ public class WebServicesMenuManager
           var item = new JMenuItem(preset.getName());
           item.addActionListener(e -> {
             lastPreset[0] = preset;
-            cancelAndRunInteractive(action, frame.getCurrentView(),
+            runAction(action, frame.getCurrentView(),
                 preset.getArguments(), Credentials.empty());
           });
           presetsMenu.add(item);
         }
       }
 
-      cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+      runAction(action, frame.getCurrentView(), arguments,
           Credentials.empty());
     }
 
@@ -433,24 +428,8 @@ public class WebServicesMenuManager
     }
   }
 
-  private void cancelInteractive(String wsName)
-  {
-    var taskRef = interactiveTasks.get(wsName);
-    if (taskRef != null && taskRef.get() != null)
-      taskRef.get().cancel();
-    interactiveTasks.put(wsName, null);
-  }
-
-  private void cancelAndRunInteractive(ActionI<?> action,
-      AlignmentViewport viewport, List<ArgumentI> args, Credentials credentials)
-  {
-    var wsName = action.getWebService().getName();
-    cancelInteractive(wsName);
-    var task = runAction(action, viewport, args, credentials);
-    interactiveTasks.put(wsName, new WeakReference<>(task));
-  }
 
-  private TaskI<?> runAction(ActionI<?> action, AlignmentViewport viewport,
+  private void runAction(ActionI<?> action, AlignmentViewport viewport,
       List<ArgumentI> args, Credentials credentials)
   {
     // casting and instance checks can be avoided with some effort,
@@ -460,16 +439,53 @@ public class WebServicesMenuManager
       // TODO: test if selection contains enough sequences
       var _action = (AlignmentAction) action;
       var handler = new AlignmentServiceGuiHandler(_action, frame);
-      return _action.perform(viewport, args, credentials, handler);
+      BaseTask<?, AlignmentResult> task = _action.createTask(viewport, args, credentials);
+      var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+      task.addTaskEventListener(handler);
+      var future = executor.submit(task);
+      task.setCancelAction(() -> { future.cancel(true); });
+      return;
     }
     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;
+    }
+    if (action instanceof PhmmerAction)
+    {
+      var _action = (PhmmerAction) action;
+      var handler = new SearchServiceGuiHandler(_action, frame);
+      TaskI<AlignmentI> task = _action.createTask(viewport, args, credentials);
+      var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+      task.addTaskEventListener(handler);
+      _action.perform(viewport, args, credentials, handler);
+      return;
     }
-    throw new IllegalArgumentException(
-        String.format("Illegal action type %s", action.getClass().getName()));
+    Console.warn(String.format(
+            "No known handler for action type %s. All output will be discarded.",
+            action.getClass().getName()));
+    var task = action.createTask(viewport, args, credentials);
+    task.addTaskEventListener(TaskEventListener.nullListener());
+    PollingTaskExecutor.fromPool(viewport.getServiceExecutor())
+      .submit(task);
   }
 
   private static CompletionStage<List<ArgumentI>> openEditParamsDialog(
index c7b6052..dcf965a 100644 (file)
@@ -1,6 +1,7 @@
 package jalview.ws2.helpers;
 
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 import jalview.ws2.actions.api.JobI;
 import jalview.ws2.actions.api.TaskEventListener;
@@ -9,52 +10,70 @@ import jalview.ws2.api.JobStatus;
 
 public class TaskEventSupport<T>
 {
-  TaskI<T> source;
-  TaskEventListener<T> handler;
-  
-  public TaskEventSupport(TaskI<T> source, TaskEventListener<T> handler)
+  private TaskI<T> source;
+
+  private List<TaskEventListener<T>> listeners = new CopyOnWriteArrayList<>();
+
+  public TaskEventSupport(TaskI<T> source)
   {
     this.source = source;
-    this.handler = handler;
+  }
+
+  public TaskEventSupport(TaskI<T> source, TaskEventListener<T> handler)
+  {
+    this(source);
+    addListener(handler);
+  }
+
+  public void addListener(TaskEventListener<T> listener)
+  {
+    listeners.add(listener);
   }
   
+  public void removeListener(TaskEventListener<T> listener)
+  {
+    listeners.remove(listener);
+  }
+
   public void fireTaskStarted(List<? extends JobI> subJobs)
   {
-    handler.taskStarted(source, subJobs);
+    for (var listener : listeners)
+      listener.taskStarted(source, subJobs);
   }
-  
+
   public void fireTaskStatusChanged(JobStatus status)
   {
-    handler.taskStatusChanged(source, status);
+    for (var listener : listeners)
+      listener.taskStatusChanged(source, status);
   }
-  
+
   public void fireTaskCompleted(T result)
   {
-    handler.taskCompleted(source, result);
+    for (var listener : listeners)
+      listener.taskCompleted(source, result);
   }
-  
+
   public void fireTaskException(Exception e)
   {
-    handler.taskException(source, e);
-  }
-  
-  public void fireTaskRestarted()
-  {
-    handler.taskRestarted(source);
+    for (var listener : listeners)
+      listener.taskException(source, e);
   }
-  
+
   public void fireSubJobStatusChanged(JobI job, JobStatus status)
   {
-    handler.subJobStatusChanged(source, job, status);
+    for (var listener : listeners)
+      listener.subJobStatusChanged(source, job, status);
   }
-  
+
   public void fireSubJobLogChanged(JobI job, String log)
   {
-    handler.subJobLogChanged(source, job, log);
+    for (var listener : listeners)
+      listener.subJobLogChanged(source, job, log);
   }
-  
+
   public void fireSubJobErrorLogChanged(JobI job, String log)
   {
-    handler.subJobErrorLogChanged(source, job, log);
+    for (var listener : listeners)
+      listener.subJobErrorLogChanged(source, job, log);
   }
 }
index 5586108..e573032 100644 (file)
@@ -24,6 +24,7 @@ import jalview.datamodel.SequenceI;
 import jalview.gui.AlignViewport;
 import jalview.viewmodel.AlignmentViewport;
 import jalview.ws.params.ParamDatastoreI;
+import jalview.ws2.actions.PollingTaskExecutor;
 import jalview.ws2.actions.api.JobI;
 import jalview.ws2.actions.api.TaskEventListener;
 import jalview.ws2.api.Credentials;
@@ -210,12 +211,16 @@ public class AlignmentActionTest
       return null;
     })
         .when(listener).taskCompleted(any(), any());
-    action.perform(viewport, List.of(), Credentials.empty(), listener);
+    var executor = PollingTaskExecutor.fromPool(viewport.getServiceExecutor());
+    var task = action.createTask(viewport, List.of(), Credentials.empty());
+    task.addTaskEventListener(listener);
+    var cancellable = executor.submit(task);
     try
     {
       latch.await(100, TimeUnit.MILLISECONDS);
     } catch (InterruptedException e)
     {
+      cancellable.cancel(true);
     }
     return listener;
   }