http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/LocalWorkerState.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/LocalWorkerState.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/LocalWorkerState.java
new file mode 100644
index 0000000..e32dcca
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/LocalWorkerState.java
@@ -0,0 +1,475 @@
+/*
+ */
+package org.taverna.server.master.localworker;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static java.io.File.separator;
+import static java.lang.System.getProperty;
+import static java.rmi.registry.Registry.REGISTRY_PORT;
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.unmodifiableList;
+import static org.taverna.server.master.defaults.Default.EXTRA_ARGUMENTS;
+import static org.taverna.server.master.defaults.Default.PASSWORD_FILE;
+import static org.taverna.server.master.defaults.Default.REGISTRY_JAR;
+import static org.taverna.server.master.defaults.Default.RMI_PREFIX;
+import static org.taverna.server.master.defaults.Default.RUN_LIFE_MINUTES;
+import static org.taverna.server.master.defaults.Default.RUN_OPERATING_LIMIT;
+import static 
org.taverna.server.master.defaults.Default.SECURE_FORK_IMPLEMENTATION_JAR;
+import static 
org.taverna.server.master.defaults.Default.SERVER_WORKER_IMPLEMENTATION_JAR;
+import static 
org.taverna.server.master.defaults.Default.SUBPROCESS_START_POLL_SLEEP;
+import static org.taverna.server.master.defaults.Default.SUBPROCESS_START_WAIT;
+import static org.taverna.server.master.localworker.PersistedState.KEY;
+import static 
org.taverna.server.master.localworker.PersistedState.makeInstance;
+
+import java.io.File;
+import java.io.FilenameFilter;
+import java.net.URI;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+import javax.jdo.annotations.PersistenceAware;
+
+import org.springframework.beans.factory.annotation.Required;
+import org.taverna.server.master.common.Status;
+import org.taverna.server.master.defaults.Default;
+import org.taverna.server.master.utils.JDOSupport;
+import org.taverna.server.master.worker.WorkerModel;
+
+/**
+ * The persistent state of a local worker factory.
+ * 
+ * @author Donal Fellows
+ */
+@PersistenceAware
+public class LocalWorkerState extends JDOSupport<PersistedState> implements
+               WorkerModel {   
+       public LocalWorkerState() {
+               super(PersistedState.class);
+       }
+
+       private LocalWorkerState self;
+
+       @Required
+       public void setSelf(LocalWorkerState self) {
+               this.self = self;
+       }
+
+       /** Initial lifetime of runs, in minutes. */
+       int defaultLifetime;
+       /**
+        * Maximum number of runs to exist at once. Note that this includes when
+        * they are just existing for the purposes of file transfer (
+        * {@link Status#Initialized}/{@link Status#Finished} states).
+        */
+       int maxRuns;
+       /**
+        * Prefix to use for RMI names.
+        */
+       String factoryProcessNamePrefix;
+       /**
+        * Full path name of the script used to start running a workflow; 
normally
+        * expected to be "<i>somewhere/</i><tt>executeWorkflow.sh</tt>".
+        */
+       String executeWorkflowScript;
+       /** Default value for {@link #executeWorkflowScript}. */
+       private transient String defaultExecuteWorkflowScript;
+       /**
+        * Full path name of the file containing the password used to launch 
workers
+        * as other users. The file is normally expected to contain a single 
line,
+        * the password, and to be thoroughly locked down so only the user 
running
+        * the server (e.g., "<tt>tomcat</tt>") can read it; it will probably 
reside
+        * in either the user's home directory or in a system configuration
+        * directory.
+        */
+       String passwordFile;
+       /** Default value for {@link #passwordFile}. */
+       private transient String defaultPasswordFile = PASSWORD_FILE;
+       /**
+        * The extra arguments to pass to the subprocess.
+        */
+       String[] extraArgs;
+       /**
+        * How long to wait for subprocess startup, in seconds.
+        */
+       int waitSeconds;
+       /**
+        * Polling interval to use during startup, in milliseconds.
+        */
+       int sleepMS;
+       /**
+        * Full path name to the worker process's implementation JAR.
+        */
+       String serverWorkerJar;
+       private static final String DEFAULT_WORKER_JAR = LocalWorkerState.class
+                       
.getClassLoader().getResource(SERVER_WORKER_IMPLEMENTATION_JAR)
+                       .getFile();
+       /**
+        * Full path name to the Java binary to use to run the subprocess.
+        */
+       String javaBinary;
+       private static final String DEFAULT_JAVA_BINARY = 
getProperty("java.home")
+                       + separator + "bin" + separator + "java";
+       /**
+        * Full path name to the secure fork process's implementation JAR.
+        */
+       String serverForkerJar;
+       private static final String DEFAULT_FORKER_JAR = LocalWorkerState.class
+                       
.getClassLoader().getResource(SECURE_FORK_IMPLEMENTATION_JAR)
+                       .getFile();
+
+       String registryHost;
+       int registryPort;
+
+       int operatingLimit;
+
+       URI[] permittedWorkflows;
+       private String registryJar;
+       private static final String DEFAULT_REGISTRY_JAR = 
LocalWorkerState.class
+                       .getClassLoader().getResource(REGISTRY_JAR).getFile();
+
+       @Override
+       public void setDefaultLifetime(int defaultLifetime) {
+               this.defaultLifetime = defaultLifetime;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public int getDefaultLifetime() {
+               return defaultLifetime < 1 ? RUN_LIFE_MINUTES : defaultLifetime;
+       }
+
+       @Override
+       public void setMaxRuns(int maxRuns) {
+               this.maxRuns = maxRuns;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public int getMaxRuns() {
+               return maxRuns < 1 ? Default.RUN_COUNT_MAX : maxRuns;
+       }
+
+       @Override
+       public int getOperatingLimit() {
+               return operatingLimit < 1 ? RUN_OPERATING_LIMIT : 
operatingLimit;
+       }
+
+       @Override
+       public void setOperatingLimit(int operatingLimit) {
+               this.operatingLimit = operatingLimit;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public void setFactoryProcessNamePrefix(String 
factoryProcessNamePrefix) {
+               this.factoryProcessNamePrefix = factoryProcessNamePrefix;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getFactoryProcessNamePrefix() {
+               return factoryProcessNamePrefix == null ? RMI_PREFIX
+                               : factoryProcessNamePrefix;
+       }
+
+       @Override
+       public void setExecuteWorkflowScript(String executeWorkflowScript) {
+               this.executeWorkflowScript = executeWorkflowScript;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getExecuteWorkflowScript() {
+               return executeWorkflowScript == null ? 
defaultExecuteWorkflowScript
+                               : executeWorkflowScript;
+       }
+
+       private static String guessWorkflowScript() {
+               File utilDir = new File(DEFAULT_WORKER_JAR).getParentFile();
+               File[] dirs = utilDir.listFiles(new FilenameFilter() {
+                       @Override
+                       public boolean accept(File dir, String name) {
+                               // Support both taverna-commandline* (2.5) and
+                               // taverna-command-line* (3.1)
+                               return 
name.toLowerCase().startsWith("taverna-command");
+                       }
+               });
+               if (dirs.length == 0) { 
+                       throw new IllegalStateException("Can't find 
taverna-command* distro in " + utilDir);
+               }
+               File script = new File(dirs[0], "executeworkflow.sh");
+               if (! script.isFile()) {
+                       throw new IllegalStateException("Can't find launcher 
script " + script);
+               }
+               return script.toString();
+       }
+
+       /**
+        * Set what executeworkflow script to use by default. This is the value 
that
+        * is used if not overridden by the administration interface.
+        * 
+        * @param defaultScript
+        *            Full path to the script to use.
+        */
+       public void setDefaultExecuteWorkflowScript(String defaultScript) {
+               if (defaultScript.startsWith("${") || 
defaultScript.equals("NONE")) {
+                       this.defaultExecuteWorkflowScript = 
guessWorkflowScript();
+                       return;
+               }
+               this.defaultExecuteWorkflowScript = defaultScript;
+       }
+
+       String getDefaultExecuteWorkflowScript() {
+               return defaultExecuteWorkflowScript;
+       }
+
+       @Override
+       public void setExtraArgs(String[] extraArgs) {
+               this.extraArgs = extraArgs.clone();
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String[] getExtraArgs() {
+               return extraArgs == null ? EXTRA_ARGUMENTS : extraArgs.clone();
+       }
+
+       @Override
+       public void setWaitSeconds(int waitSeconds) {
+               this.waitSeconds = waitSeconds;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public int getWaitSeconds() {
+               return waitSeconds < 1 ? SUBPROCESS_START_WAIT : waitSeconds;
+       }
+
+       @Override
+       public void setSleepMS(int sleepMS) {
+               this.sleepMS = sleepMS;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public int getSleepMS() {
+               return sleepMS < 1 ? SUBPROCESS_START_POLL_SLEEP : sleepMS;
+       }
+
+       @Override
+       public void setServerWorkerJar(String serverWorkerJar) {
+               this.serverWorkerJar = serverWorkerJar;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getServerWorkerJar() {
+               return serverWorkerJar == null ? DEFAULT_WORKER_JAR : 
serverWorkerJar;
+       }
+
+       @Override
+       public void setServerForkerJar(String serverForkerJar) {
+               this.serverForkerJar = serverForkerJar;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getServerForkerJar() {
+               return serverForkerJar == null ? DEFAULT_FORKER_JAR : 
serverForkerJar;
+       }
+
+       @Override
+       public void setJavaBinary(String javaBinary) {
+               this.javaBinary = javaBinary;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getJavaBinary() {
+               return javaBinary == null ? DEFAULT_JAVA_BINARY : javaBinary;
+       }
+
+       @Override
+       public void setPasswordFile(String passwordFile) {
+               this.passwordFile = passwordFile;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getPasswordFile() {
+               return passwordFile == null ? defaultPasswordFile : 
passwordFile;
+       }
+
+       void setDefaultPasswordFile(String defaultPasswordFile) {
+               this.defaultPasswordFile = defaultPasswordFile;
+       }
+
+       @Override
+       public void setRegistryHost(String registryHost) {
+               this.registryHost = (registryHost == null ? "" : registryHost);
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public String getRegistryHost() {
+               return (registryHost == null || registryHost.isEmpty()) ? null
+                               : registryHost;
+       }
+
+       @Override
+       public void setRegistryPort(int registryPort) {
+               this.registryPort = ((registryPort < 1 || registryPort > 65534) 
? REGISTRY_PORT
+                               : registryPort);
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public int getRegistryPort() {
+               return registryPort == 0 ? REGISTRY_PORT : registryPort;
+       }
+
+       @Override
+       public String getRegistryJar() {
+               return registryJar == null ? DEFAULT_REGISTRY_JAR : registryJar;
+       }
+
+       @Override
+       public void setRegistryJar(String rmiRegistryJar) {
+               this.registryJar = (rmiRegistryJar == null || 
rmiRegistryJar.isEmpty()) ? null
+                               : rmiRegistryJar;
+               if (loadedState)
+                       self.store();
+       }
+
+       @Override
+       public List<URI> getPermittedWorkflowURIs() {
+               if (permittedWorkflows == null || permittedWorkflows.length == 
0)
+                       return emptyList();
+               return unmodifiableList(asList(permittedWorkflows));
+       }
+
+       @Override
+       public void setPermittedWorkflowURIs(List<URI> permittedWorkflows) {
+               if (permittedWorkflows == null || permittedWorkflows.isEmpty())
+                       this.permittedWorkflows = new URI[0];
+               else
+                       this.permittedWorkflows = permittedWorkflows
+                                       .toArray(new 
URI[permittedWorkflows.size()]);
+               if (loadedState)
+                       self.store();
+       }
+
+       public static final boolean DEFAULT_GENERATE_PROVENANCE = false;
+       private Boolean generateProvenance;
+
+       @Override
+       public boolean getGenerateProvenance() {
+               Boolean g = generateProvenance;
+               return g == null ? DEFAULT_GENERATE_PROVENANCE : (boolean) g;
+       }
+
+       @Override
+       public void setGenerateProvenance(boolean generate) {
+               this.generateProvenance = generate;
+               if (loadedState)
+                       self.store();
+       }
+
+       // --------------------------------------------------------------
+
+       private boolean loadedState;
+
+       @PostConstruct
+       @WithinSingleTransaction
+       public void load() {
+               if (loadedState || !isPersistent())
+                       return;
+               WorkerModel state = getById(KEY);
+               if (state == null) {
+                       store();
+                       return;
+               }
+
+               defaultLifetime = state.getDefaultLifetime();
+               executeWorkflowScript = state.getExecuteWorkflowScript();
+               extraArgs = state.getExtraArgs();
+               factoryProcessNamePrefix = state.getFactoryProcessNamePrefix();
+               javaBinary = state.getJavaBinary();
+               maxRuns = state.getMaxRuns();
+               serverWorkerJar = state.getServerWorkerJar();
+               serverForkerJar = state.getServerForkerJar();
+               passwordFile = state.getPasswordFile();
+               sleepMS = state.getSleepMS();
+               waitSeconds = state.getWaitSeconds();
+               registryHost = state.getRegistryHost();
+               registryPort = state.getRegistryPort();
+               operatingLimit = state.getOperatingLimit();
+               List<URI> pwu = state.getPermittedWorkflowURIs();
+               permittedWorkflows = (URI[]) pwu.toArray(new URI[pwu.size()]);
+               registryJar = state.getRegistryJar();
+               generateProvenance = state.getGenerateProvenance();
+
+               loadedState = true;
+       }
+
+       @WithinSingleTransaction
+       public void store() {
+               if (!isPersistent())
+                       return;
+               WorkerModel state = getById(KEY);
+               if (state == null)
+                       state = persist(makeInstance());
+
+               state.setDefaultLifetime(defaultLifetime);
+               state.setExecuteWorkflowScript(executeWorkflowScript);
+               state.setExtraArgs(extraArgs);
+               state.setFactoryProcessNamePrefix(factoryProcessNamePrefix);
+               state.setJavaBinary(javaBinary);
+               state.setMaxRuns(maxRuns);
+               state.setServerWorkerJar(serverWorkerJar);
+               state.setServerForkerJar(serverForkerJar);
+               state.setPasswordFile(passwordFile);
+               state.setSleepMS(sleepMS);
+               state.setWaitSeconds(waitSeconds);
+               state.setRegistryHost(registryHost);
+               state.setRegistryPort(registryPort);
+               state.setOperatingLimit(operatingLimit);
+               if (permittedWorkflows != null)
+                       
state.setPermittedWorkflowURIs(asList(permittedWorkflows));
+               state.setRegistryJar(registryJar);
+               if (generateProvenance != null)
+                       state.setGenerateProvenance(generateProvenance);
+
+               loadedState = true;
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/PersistedState.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/PersistedState.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/PersistedState.java
new file mode 100644
index 0000000..3ed4c51
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/PersistedState.java
@@ -0,0 +1,270 @@
+/*
+ */
+package org.taverna.server.master.localworker;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.jdo.annotations.Join;
+import javax.jdo.annotations.PersistenceCapable;
+import javax.jdo.annotations.Persistent;
+import javax.jdo.annotations.PrimaryKey;
+
+import org.taverna.server.master.worker.WorkerModel;
+
+/**
+ * The actual database connector for persisted local worker state.
+ * 
+ * @author Donal Fellows
+ */
+/*
+ * WARNING! If you change the name of this class, update persistence.xml as
+ * well!
+ */
+@PersistenceCapable(table = PersistedState.TABLE)
+class PersistedState implements WorkerModel {
+       static final String TABLE = "LOCALWORKERSTATE__PERSISTEDSTATE";
+
+       static PersistedState makeInstance() {
+               PersistedState o = new PersistedState();
+               o.ID = KEY;
+               return o;
+       }
+
+       @PrimaryKey(column = "ID")
+       protected int ID;
+
+       static final int KEY = 32;
+
+       @Persistent
+       private int defaultLifetime;
+       @Persistent
+       private int maxRuns;
+       @Persistent
+       private String factoryProcessNamePrefix;
+       @Persistent
+       private String executeWorkflowScript;
+       @Persistent(serialized = "true")
+       private String[] extraArgs;
+       @Persistent
+       private int waitSeconds;
+       @Persistent
+       private int sleepMS;
+       @Persistent
+       private String serverWorkerJar;
+       @Persistent
+       private String serverForkerJar;
+       @Persistent
+       private String registryJar;
+       @Persistent
+       private String passwordFile;
+       @Persistent
+       private String javaBinary;
+       @Persistent
+       private int registryPort;
+       @Persistent
+       private String registryHost;
+       @Persistent
+       private int operatingLimit;
+       @Persistent(defaultFetchGroup = "true")
+       @Join(table = TABLE + "_PERMWFURI", column = "ID")
+       private String[] permittedWorkflows;
+       @Persistent
+       private int generateProvenance;
+
+       @Override
+       public void setDefaultLifetime(int defaultLifetime) {
+               this.defaultLifetime = defaultLifetime;
+       }
+
+       @Override
+       public int getDefaultLifetime() {
+               return defaultLifetime;
+       }
+
+       @Override
+       public void setMaxRuns(int maxRuns) {
+               this.maxRuns = maxRuns;
+       }
+
+       @Override
+       public int getMaxRuns() {
+               return maxRuns;
+       }
+
+       @Override
+       public void setFactoryProcessNamePrefix(String 
factoryProcessNamePrefix) {
+               this.factoryProcessNamePrefix = factoryProcessNamePrefix;
+       }
+
+       @Override
+       public String getFactoryProcessNamePrefix() {
+               return factoryProcessNamePrefix;
+       }
+
+       @Override
+       public void setExecuteWorkflowScript(String executeWorkflowScript) {
+               this.executeWorkflowScript = executeWorkflowScript;
+       }
+
+       @Override
+       public String getExecuteWorkflowScript() {
+               return executeWorkflowScript;
+       }
+
+       @Override
+       public void setExtraArgs(String[] extraArgs) {
+               this.extraArgs = extraArgs;
+       }
+
+       @Override
+       public String[] getExtraArgs() {
+               return extraArgs;
+       }
+
+       @Override
+       public void setWaitSeconds(int waitSeconds) {
+               this.waitSeconds = waitSeconds;
+       }
+
+       @Override
+       public int getWaitSeconds() {
+               return waitSeconds;
+       }
+
+       @Override
+       public void setSleepMS(int sleepMS) {
+               this.sleepMS = sleepMS;
+       }
+
+       @Override
+       public int getSleepMS() {
+               return sleepMS;
+       }
+
+       @Override
+       public void setServerWorkerJar(String serverWorkerJar) {
+               this.serverWorkerJar = serverWorkerJar;
+       }
+
+       @Override
+       public String getServerWorkerJar() {
+               return serverWorkerJar;
+       }
+
+       @Override
+       public void setJavaBinary(String javaBinary) {
+               this.javaBinary = javaBinary;
+       }
+
+       @Override
+       public String getJavaBinary() {
+               return javaBinary;
+       }
+
+       @Override
+       public void setRegistryPort(int registryPort) {
+               this.registryPort = registryPort;
+       }
+
+       @Override
+       public int getRegistryPort() {
+               return registryPort;
+       }
+
+       @Override
+       public void setRegistryHost(String registryHost) {
+               this.registryHost = registryHost;
+       }
+
+       @Override
+       public String getRegistryHost() {
+               return registryHost;
+       }
+
+       @Override
+       public void setServerForkerJar(String serverForkerJar) {
+               this.serverForkerJar = serverForkerJar;
+       }
+
+       @Override
+       public String getServerForkerJar() {
+               return serverForkerJar;
+       }
+
+       @Override
+       public void setPasswordFile(String passwordFile) {
+               this.passwordFile = passwordFile;
+       }
+
+       @Override
+       public String getPasswordFile() {
+               return passwordFile;
+       }
+
+       @Override
+       public void setOperatingLimit(int operatingLimit) {
+               this.operatingLimit = operatingLimit;
+       }
+
+       @Override
+       public int getOperatingLimit() {
+               return operatingLimit;
+       }
+
+       @Override
+       public List<URI> getPermittedWorkflowURIs() {
+               String[] pw = this.permittedWorkflows;
+               if (pw == null)
+                       return new ArrayList<>();
+               List<URI> uris = new ArrayList<>(pw.length);
+               for (String uri : pw)
+                       uris.add(URI.create(uri));
+               return uris;
+       }
+
+       @Override
+       public void setPermittedWorkflowURIs(List<URI> permittedWorkflows) {
+               String[] pw = new String[permittedWorkflows.size()];
+               for (int i = 0; i < pw.length; i++)
+                       pw[i] = permittedWorkflows.get(i).toString();
+               this.permittedWorkflows = pw;
+       }
+
+       @Override
+       public String getRegistryJar() {
+               return registryJar;
+       }
+
+       @Override
+       public void setRegistryJar(String registryJar) {
+               this.registryJar = registryJar;
+       }
+
+       @Override
+       public boolean getGenerateProvenance() {
+               return generateProvenance > 0;
+       }
+
+       @Override
+       public void setGenerateProvenance(boolean generateProvenance) {
+               this.generateProvenance = (generateProvenance ? 1 : 0);
+       }
+}
\ No newline at end of file

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/StreamLogger.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/StreamLogger.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/StreamLogger.java
new file mode 100644
index 0000000..e563965
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/StreamLogger.java
@@ -0,0 +1,78 @@
+package org.taverna.server.master.localworker;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static java.lang.Thread.interrupted;
+import static org.apache.commons.logging.LogFactory.getLog;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+import org.apache.commons.logging.Log;
+
+abstract class StreamLogger {
+       protected final Log log;
+       private Thread t;
+       private InputStream in;
+
+       protected StreamLogger(final String name, InputStream is) {
+               log = getLog("Taverna.Server.LocalWorker." + name);
+               in = is;
+               t = new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try (BufferedReader br = new BufferedReader(
+                                               new InputStreamReader(in))) {
+                                       String line;
+                                       while (!interrupted() && (line = 
br.readLine()) != null)
+                                               if (!line.isEmpty())
+                                                       write(line);
+                               } catch (IOException e) {
+                                       // Do nothing...
+                               } catch (Exception e) {
+                                       log.warn("failure in reading from " + 
name, e);
+                               }
+                       }
+               }, name + ".StreamLogger");
+               t.setContextClassLoader(null);
+               t.setDaemon(true);
+               t.start();
+       }
+
+       /**
+        * Write a line read from the subprocess to the log.
+        * <p>
+        * This needs to be implemented by subclasses in order for the log to be
+        * correctly written with the class name.
+        * 
+        * @param msg
+        *            The message to write. Guaranteed to have no newline 
characters
+        *            in it and to be non-empty.
+        */
+       protected abstract void write(String msg);
+
+       public void stop() {
+               log.info("trying to close down " + t.getName());
+               t.interrupt();
+               try {
+                       in.close();
+               } catch (IOException e) {
+               }
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/package-info.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/package-info.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/package-info.java
new file mode 100644
index 0000000..031ce34
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/localworker/package-info.java
@@ -0,0 +1,23 @@
+/*
+ */
+/**
+ * Implementation of a Taverna Server back-end that works by forking off
+ * workflow executors on the local system.
+ */
+package org.taverna.server.master.localworker;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/EmailDispatcher.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/EmailDispatcher.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/EmailDispatcher.java
new file mode 100644
index 0000000..2a51496
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/EmailDispatcher.java
@@ -0,0 +1,126 @@
+/*
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
+
+import javax.annotation.PostConstruct;
+
+import org.springframework.beans.factory.annotation.Required;
+import org.springframework.mail.MailSender;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+
+/**
+ * How to send a plain text message by email to someone.
+ * 
+ * @author Donal Fellows
+ */
+public class EmailDispatcher extends RateLimitedDispatcher {
+       @Override
+       public String getName() {
+               return "mailto";
+       }
+
+       /**
+        * @param from
+        *            Email address that the notification is to come from.
+        */
+       @Required
+       public void setFrom(String from) {
+               this.from = valid(from, "");
+       }
+
+       /**
+        * @param host
+        *            The outgoing SMTP server address.
+        */
+       @Required
+       public void setSmtpHost(String host) {
+               this.host = valid(host, "");
+       }
+
+       /**
+        * @param contentType
+        *            The content type of the message to be sent. For example, "
+        *            <tt>text/plain</tt>".
+        */
+       public void setMessageContentType(String contentType) {
+               this.contentType = contentType;
+       }
+
+       /**
+        * @param sender
+        *            the sender to set
+        */
+       public void setSender(MailSender sender) {
+               this.sender = sender;
+       }
+
+       private String from;
+       private String host;
+       private MailSender sender;
+       @SuppressWarnings("unused")
+       private String contentType = TEXT_PLAIN;
+
+       /**
+        * Try to perform the lookup of the email service. This is called during
+        * configuration so that any failure happens at a useful, predictable 
time.
+        */
+       @PostConstruct
+       public void tryLookup() {
+               if (!isAvailable()) {
+                       log.warn("no mail support; disabling email dispatch");
+                       sender = null;
+                       return;
+               }
+               try {
+                       if (sender instanceof JavaMailSender)
+                               ((JavaMailSender) sender).createMimeMessage();
+               } catch (Throwable t) {
+                       log.warn("sender having problems constructing messages; 
"
+                                       + "disabling...", t);
+                       sender = null;
+               }
+       }
+
+       @Override
+       public void dispatch(String messageSubject, String messageContent, 
String to)
+                       throws Exception {
+               // Simple checks for acceptability
+               if (!to.matches(".+@.+")) {
+                       log.info("did not send email notification: improper 
email address \""
+                                       + to + "\"");
+                       return;
+               }
+
+               SimpleMailMessage message = new SimpleMailMessage();
+               message.setFrom(from);
+               message.setTo(to.trim());
+               message.setSubject(messageSubject);
+               message.setText(messageContent);
+               sender.send(message);
+       }
+
+       @Override
+       public boolean isAvailable() {
+               return (host != null && !host.isEmpty() && sender != null
+                               && from != null && !from.isEmpty());
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/JabberDispatcher.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/JabberDispatcher.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/JabberDispatcher.java
new file mode 100644
index 0000000..711f4e6
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/JabberDispatcher.java
@@ -0,0 +1,153 @@
+/*
+ */
+
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.jivesoftware.smack.Chat;
+import org.jivesoftware.smack.ConnectionConfiguration;
+import org.jivesoftware.smack.MessageListener;
+import org.jivesoftware.smack.XMPPConnection;
+import org.jivesoftware.smack.packet.Message;
+import org.taverna.server.master.interfaces.MessageDispatcher;
+import org.taverna.server.master.interfaces.TavernaRun;
+
+/**
+ * Send notifications by Jabber/XMPP.
+ * 
+ * @author Donal Fellows
+ */
+public class JabberDispatcher implements MessageDispatcher {
+       @Override
+       public String getName() {
+               return "xmpp";
+       }
+
+       private Log log = LogFactory.getLog("Taverna.Server.Notification");
+       private XMPPConnection conn;
+       private String resource = "TavernaServer";
+       private String host = "";
+       private String user = "";
+       private String pass = "";
+
+       /**
+        * @param resource
+        *            The XMPP resource to use when connecting the server. This
+        *            defaults to "<tt>TavernaServer</tt>".
+        */
+       public void setResource(String resource) {
+               this.resource = resource;
+       }
+
+       /**
+        * @param service
+        *            The XMPP service URL.
+        */
+       public void setHost(String service) {
+               if (service == null || service.trim().isEmpty()
+                               || service.trim().startsWith("$"))
+                       this.host = "";
+               else
+                       this.host = service.trim();
+       }
+
+       /**
+        * @param user
+        *            The user identity to use with the XMPP service.
+        */
+       public void setUsername(String user) {
+               if (user == null || user.trim().isEmpty()
+                               || user.trim().startsWith("$"))
+                       this.user = "";
+               else
+                       this.user = user.trim();
+       }
+
+       /**
+        * @param pass
+        *            The password to use with the XMPP service.
+        */
+       public void setPassword(String pass) {
+               if (pass == null || pass.trim().isEmpty()
+                               || pass.trim().startsWith("$"))
+                       this.pass = "";
+               else
+                       this.pass = pass.trim();
+       }
+
+       @PostConstruct
+       void setup() {
+               try {
+                       if (host.isEmpty() || user.isEmpty() || pass.isEmpty()) 
{
+                               log.info("disabling XMPP support; incomplete 
configuration");
+                               conn = null;
+                               return;
+                       }
+                       ConnectionConfiguration cfg = new 
ConnectionConfiguration(host);
+                       cfg.setSendPresence(false);
+                       XMPPConnection c = new XMPPConnection(cfg);
+                       c.connect();
+                       c.login(user, pass, resource);
+                       conn = c;
+                       log.info("connected to XMPP service <" + host + "> as 
user <"
+                                       + user + ">");
+               } catch (Exception e) {
+                       log.info("failed to connect to XMPP server", e);
+               }
+       }
+
+       @PreDestroy
+       public void close() {
+               if (conn != null)
+                       conn.disconnect();
+               conn = null;
+       }
+
+       @Override
+       public boolean isAvailable() {
+               return conn != null;
+       }
+
+       @Override
+       public void dispatch(TavernaRun ignored, String messageSubject,
+                       String messageContent, String targetParameter) throws 
Exception {
+               Chat chat = conn.getChatManager().createChat(targetParameter,
+                               new DroppingListener());
+               Message m = new Message();
+               m.addBody(null, messageContent);
+               m.setSubject(messageSubject);
+               chat.sendMessage(m);
+       }
+
+       static class DroppingListener implements MessageListener {
+               private Log log = LogFactory
+                               .getLog("Taverna.Server.Notification.Jabber");
+
+               @Override
+               public void processMessage(Chat chat, Message message) {
+                       if (log.isDebugEnabled())
+                               log.debug("unexpectedly received XMPP message 
from <"
+                                               + message.getFrom() + ">; 
ignoring");
+               }
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/NotificationEngine.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/NotificationEngine.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/NotificationEngine.java
new file mode 100644
index 0000000..067d154
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/NotificationEngine.java
@@ -0,0 +1,158 @@
+/*
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.annotation.Required;
+import org.taverna.server.master.interfaces.MessageDispatcher;
+import org.taverna.server.master.interfaces.TavernaRun;
+
+/**
+ * A common object for handling dispatch of event-driven messages.
+ * 
+ * @author Donal Fellows
+ */
+public class NotificationEngine {
+       private Log log = LogFactory.getLog("Taverna.Server.Notification");
+       private Map<String, MessageDispatcher> dispatchers;
+       private List<MessageDispatcher> universalDispatchers;
+
+       /**
+        * @param dispatchers
+        *            The various dispatchers we want to install.
+        */
+       @Required
+       public void setDispatchers(List<MessageDispatcher> dispatchers) {
+               this.dispatchers = new HashMap<>();
+               for (MessageDispatcher d : dispatchers)
+                       this.dispatchers.put(d.getName(), d);
+       }
+
+       /**
+        * @param dispatcherList
+        *            A list of dispatch objects to always dispatch to.
+        */
+       @Required
+       public void setUniversalDispatchers(List<MessageDispatcher> 
dispatcherList) {
+               this.universalDispatchers = dispatcherList;
+       }
+
+       private void dispatchToChosenTarget(TavernaRun originator, String 
scheme,
+                       String target, Message message) throws Exception {
+               try {
+                       MessageDispatcher d = dispatchers.get(scheme);
+                       if (d != null && d.isAvailable())
+                               d.dispatch(originator, message.getTitle(scheme),
+                                               message.getContent(scheme), 
target);
+                       else
+                               log.warn("no such notification dispatcher for " 
+ scheme);
+               } catch (URISyntaxException e) {
+                       // See if *someone* will handle the message
+                       Exception e2 = null;
+                       for (MessageDispatcher d : dispatchers.values())
+                               try {
+                                       if (d.isAvailable()) {
+                                               d.dispatch(originator, 
message.getTitle(d.getName()),
+                                                               
message.getContent(d.getName()), scheme + ":"
+                                                                               
+ target);
+                                               return;
+                                       }
+                               } catch (Exception ex) {
+                                       if (log.isDebugEnabled())
+                                               log.debug("failed in 
pseudo-directed dispatch of "
+                                                               + scheme + ":" 
+ target, ex);
+                                       e2 = ex;
+                               }
+                       if (e2 != null)
+                               throw e2;
+               }
+       }
+
+       private void dispatchUniversally(TavernaRun originator, Message message)
+                       throws Exception {
+               for (MessageDispatcher d : universalDispatchers)
+                       try {
+                               if (d.isAvailable())
+                                       d.dispatch(originator, 
message.getTitle(d.getName()),
+                                                       
message.getContent(d.getName()), null);
+                       } catch (Exception e) {
+                               log.warn("problem in universal dispatcher", e);
+                       }
+       }
+
+       /**
+        * Dispatch a message over the notification fabric.
+        * 
+        * @param originator
+        *            What workflow run was the source of this message?
+        * @param destination
+        *            Where the message should get delivered to. The correct 
format
+        *            of this is either as a URI of some form (where the scheme
+        *            determines the dispatcher) or as an invalid URI in which 
case
+        *            it is just tried against the possibilities to see if any
+        *            succeeds.
+        * @param subject
+        *            The subject line of the message.
+        * @param message
+        *            The plain text body of the message.
+        * @throws Exception
+        *             If anything goes wrong with the dispatch process.
+        */
+       public void dispatchMessage(TavernaRun originator, String destination,
+                       Message message) throws Exception {
+               if (destination != null && !destination.trim().isEmpty()) {
+                       try {
+                               URI toURI = new URI(destination.trim());
+                               dispatchToChosenTarget(originator, 
toURI.getScheme(),
+                                               toURI.getSchemeSpecificPart(), 
message);
+                       } catch (URISyntaxException e) {
+                               // Ignore
+                       }
+               }
+               dispatchUniversally(originator, message);
+       }
+
+       /**
+        * @return The message dispatchers that are actually available (i.e., 
not
+        *         disabled by configuration somewhere).
+        */
+       public List<String> listAvailableDispatchers() {
+               ArrayList<String> result = new ArrayList<>();
+               for (Map.Entry<String, MessageDispatcher> entry : dispatchers
+                               .entrySet()) {
+                       if (entry.getValue().isAvailable())
+                               result.add(entry.getKey());
+               }
+               return result;
+       }
+
+       public interface Message {
+               String getContent(String type);
+
+               String getTitle(String type);
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/RateLimitedDispatcher.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/RateLimitedDispatcher.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/RateLimitedDispatcher.java
new file mode 100644
index 0000000..a41e23b
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/RateLimitedDispatcher.java
@@ -0,0 +1,102 @@
+/*
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.joda.time.DateTime;
+import org.taverna.server.master.interfaces.MessageDispatcher;
+import org.taverna.server.master.interfaces.TavernaRun;
+
+/**
+ * Rate-limiting support. Some message fabrics simply should not be used to 
send
+ * a lot of messages.
+ * 
+ * @author Donal Fellows
+ */
+public abstract class RateLimitedDispatcher implements MessageDispatcher {
+       /** Pre-configured logger. */
+       protected Log log = LogFactory.getLog("Taverna.Server.Notification");
+       private int cooldownSeconds;
+       private Map<String, DateTime> lastSend = new HashMap<>();
+
+       String valid(String value, String def) {
+               if (value == null || value.trim().isEmpty()
+                               || value.trim().startsWith("${"))
+                       return def;
+               else
+                       return value.trim();
+       }
+
+       /**
+        * Set how long must elapse between updates to the status of any 
particular
+        * user. Calls before that time are just silently dropped.
+        * 
+        * @param cooldownSeconds
+        *            Time to elapse, in seconds.
+        */
+       public void setCooldownSeconds(int cooldownSeconds) {
+               this.cooldownSeconds = cooldownSeconds;
+       }
+
+       /**
+        * Test whether the rate limiter allows the given user to send a 
message.
+        * 
+        * @param who
+        *            Who wants to send the message?
+        * @return <tt>true</tt> iff they are permitted.
+        */
+       protected boolean isSendAllowed(String who) {
+               DateTime now = new DateTime();
+               synchronized (lastSend) {
+                       DateTime last = lastSend.get(who);
+                       if (last != null) {
+                               if 
(!now.isAfter(last.plusSeconds(cooldownSeconds)))
+                                       return false;
+                       }
+                       lastSend.put(who, now);
+               }
+               return true;
+       }
+
+       @Override
+       public void dispatch(TavernaRun ignored, String messageSubject,
+                       String messageContent, String target) throws Exception {
+               if (isSendAllowed(target))
+                       dispatch(messageSubject, messageContent, target);
+       }
+
+       /**
+        * Dispatch a message to a recipient that doesn't care what produced it.
+        * 
+        * @param messageSubject
+        *            The subject of the message to send.
+        * @param messageContent
+        *            The plain-text content of the message to send.
+        * @param target
+        *            A description of where it is to go.
+        * @throws Exception
+        *             If anything goes wrong.
+        */
+       public abstract void dispatch(String messageSubject, String 
messageContent,
+                       String target) throws Exception;
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/SMSDispatcher.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/SMSDispatcher.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/SMSDispatcher.java
new file mode 100644
index 0000000..559f111
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/SMSDispatcher.java
@@ -0,0 +1,171 @@
+/*
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static org.taverna.server.master.defaults.Default.SMS_GATEWAY_URL;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.entity.UrlEncodedFormEntity;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.message.BasicNameValuePair;
+import org.springframework.beans.factory.annotation.Required;
+
+/**
+ * Dispatch termination messages via SMS.
+ * 
+ * @author Donal Fellows
+ */
+public class SMSDispatcher extends RateLimitedDispatcher {
+       @Override
+       public String getName() {
+               return "sms";
+       }
+
+       private CloseableHttpClient client;
+       private URI service;
+       private String user = "", pass = "";
+       private String usernameField = "username", passwordField = "password",
+                       destinationField = "to", messageField = "text";
+
+       /**
+        * @param usernameField
+        *            The name of the field that conveys the sending username; 
this
+        *            is the <i>server</i>'s identity.
+        */
+       @Required
+       public void setUsernameField(String usernameField) {
+               this.usernameField = usernameField;
+       }
+
+       /**
+        * @param passwordField
+        *            The field holding the password to authenticate the server 
to
+        *            the SMS gateway.
+        */
+       @Required
+       public void setPasswordField(String passwordField) {
+               this.passwordField = passwordField;
+       }
+
+       /**
+        * @param destinationField
+        *            The field holding the number to send the SMS to.
+        */
+       @Required
+       public void setDestinationField(String destinationField) {
+               this.destinationField = destinationField;
+       }
+
+       /**
+        * @param messageField
+        *            The field holding the plain-text message to send.
+        */
+       @Required
+       public void setMessageField(String messageField) {
+               this.messageField = messageField;
+       }
+
+       public void setService(String serviceURL) {
+               String s = valid(serviceURL, "");
+               if (s.isEmpty()) {
+                       log.warn("did not get sms.service from servlet config; 
using default ("
+                                       + SMS_GATEWAY_URL + ")");
+                       s = SMS_GATEWAY_URL;
+               }
+               try {
+                       service = new URI(s);
+               } catch (URISyntaxException e) {
+                       service = null;
+               }
+       }
+
+       public void setUser(String user) {
+               this.user = valid(user, "");
+       }
+
+       public void setPassword(String pass) {
+               this.pass = valid(pass, "");
+       }
+
+       @PostConstruct
+       void init() {
+               client = HttpClientBuilder.create().build();
+       }
+
+       @PreDestroy
+       void close() throws IOException {
+               try {
+                       if (client != null)
+                               client.close();
+               } finally {
+                       client = null;
+               }
+       }
+
+       @Override
+       public boolean isAvailable() {
+               return service != null && !user.isEmpty() && !pass.isEmpty();
+       }
+
+       @Override
+       public void dispatch(String messageSubject, String messageContent,
+                       String targetParameter) throws Exception {
+               // Sanity check
+               if (!targetParameter.matches("[^0-9]+"))
+                       throw new Exception("invalid phone number");
+
+               if (!isSendAllowed("anyone"))
+                       return;
+
+               // Build the message to send
+               List<NameValuePair> params = new ArrayList<>();
+               params.add(new BasicNameValuePair(usernameField, user));
+               params.add(new BasicNameValuePair(passwordField, pass));
+               params.add(new BasicNameValuePair(destinationField, 
targetParameter));
+               params.add(new BasicNameValuePair(messageField, 
messageContent));
+
+               // Send the message
+               HttpPost post = new HttpPost(service);
+               post.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
+               HttpResponse response = client.execute(post);
+
+               // Log the response
+               HttpEntity entity = response.getEntity();
+               if (entity != null)
+                       try (BufferedReader e = new BufferedReader(new 
InputStreamReader(
+                                       entity.getContent()))) {
+                               log.info(e.readLine());
+                       }
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/TwitterDispatcher.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/TwitterDispatcher.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/TwitterDispatcher.java
new file mode 100644
index 0000000..a0269a5
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/TwitterDispatcher.java
@@ -0,0 +1,145 @@
+/*
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.util.Properties;
+
+import twitter4j.Twitter;
+import twitter4j.TwitterFactory;
+import twitter4j.conf.Configuration;
+import twitter4j.conf.PropertyConfiguration;
+import twitter4j.auth.AuthorizationFactory;
+
+/**
+ * Super simple-minded twitter dispatcher. You need to tell it your consumer 
key
+ * and secret as part of the connection parameters, for example via a 
dispatcher
+ * URN of "<tt>twitter:fred:bloggs</tt>" where <tt>fred</tt> is the key and
+ * <tt>bloggs</tt> is the secret.
+ * 
+ * @author Donal Fellows
+ */
+public class TwitterDispatcher extends RateLimitedDispatcher {
+       @Override
+       public String getName() {
+               return "twitter";
+       }
+
+       public static final int MAX_MESSAGE_LENGTH = 140;
+       public static final char ELLIPSIS = '\u2026';
+
+       private String token = "";
+       private String secret = "";
+
+       public void setAccessToken(String token) {
+               this.token = valid(token, "");
+       }
+
+       public void setAccessSecret(String secret) {
+               this.secret = valid(secret, "");
+       }
+
+       private Properties getConfig() throws NotConfiguredException {
+               if (token.isEmpty() || secret.isEmpty())
+                       throw new NotConfiguredException();
+               Properties p = new Properties();
+               p.setProperty(ACCESS_TOKEN_PROP, token);
+               p.setProperty(ACCESS_SECRET_PROP, secret);
+               return p;
+       }
+
+       public static final String ACCESS_TOKEN_PROP = "oauth.accessToken";
+       public static final String ACCESS_SECRET_PROP = 
"oauth.accessTokenSecret";
+
+       private Twitter getTwitter(String key, String secret) throws Exception {
+               if (key.isEmpty() || secret.isEmpty())
+                       throw new NoCredentialsException();
+
+               Properties p = getConfig();
+               p.setProperty("oauth.consumerKey", key);
+               p.setProperty("oauth.consumerSecret", secret);
+
+               Configuration config = new PropertyConfiguration(p);
+               TwitterFactory factory = new TwitterFactory(config);
+               Twitter t = factory.getInstance(AuthorizationFactory
+                               .getInstance(config));
+               // Verify that we can connect!
+               t.getOAuthAccessToken();
+               return t;
+       }
+
+       // TODO: Get secret from credential manager
+       @Override
+       public void dispatch(String messageSubject, String messageContent,
+                       String targetParameter) throws Exception {
+               // messageSubject ignored
+               String[] target = targetParameter.split(":", 2);
+               if (target == null || target.length != 2)
+                       throw new Exception("missing consumer key or secret");
+               String who = target[0];
+               if (!isSendAllowed(who))
+                       return;
+               Twitter twitter = getTwitter(who, target[1]);
+
+               if (messageContent.length() > MAX_MESSAGE_LENGTH)
+                       messageContent = messageContent
+                                       .substring(0, MAX_MESSAGE_LENGTH - 1) + 
ELLIPSIS;
+               twitter.updateStatus(messageContent);
+       }
+
+       @Override
+       public boolean isAvailable() {
+               try {
+                       // Try to create the configuration and push it through 
as far as
+                       // confirming that we can build an access object (even 
if it isn't
+                       // bound to a user)
+                       new TwitterFactory(new 
PropertyConfiguration(getConfig()))
+                                       .getInstance();
+                       return true;
+               } catch (Exception e) {
+                       return false;
+               }
+       }
+
+       /**
+        * Indicates that the dispatcher has not been configured with service
+        * credentials.
+        * 
+        * @author Donal Fellows
+        */
+       @SuppressWarnings("serial")
+       public static class NotConfiguredException extends Exception {
+               NotConfiguredException() {
+                       super("not configured with xAuth key and secret; "
+                                       + "dispatch not possible");
+               }
+       }
+
+       /**
+        * Indicates that the user did not supply their credentials.
+        * 
+        * @author Donal Fellows
+        */
+       @SuppressWarnings("serial")
+       public static class NoCredentialsException extends Exception {
+               NoCredentialsException() {
+                       super("no consumer key and secret present; "
+                                       + "dispatch not possible");
+               }
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/AtomFeed.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/AtomFeed.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/AtomFeed.java
new file mode 100644
index 0000000..eda6d9d
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/AtomFeed.java
@@ -0,0 +1,147 @@
+/*
+ */
+package org.taverna.server.master.notification.atom;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static java.lang.String.format;
+import static java.util.UUID.randomUUID;
+import static javax.ws.rs.core.UriBuilder.fromUri;
+import static org.taverna.server.master.common.Roles.USER;
+import static org.taverna.server.master.common.Uri.secure;
+
+import java.net.URI;
+import java.util.Date;
+
+import javax.annotation.security.RolesAllowed;
+import javax.servlet.ServletContext;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+
+import org.apache.abdera.Abdera;
+import org.apache.abdera.model.Entry;
+import org.apache.abdera.model.Feed;
+import org.springframework.beans.factory.annotation.Required;
+import org.springframework.web.context.ServletContextAware;
+import org.taverna.server.master.TavernaServerSupport;
+import org.taverna.server.master.interfaces.TavernaRun;
+import org.taverna.server.master.interfaces.UriBuilderFactory;
+import org.taverna.server.master.rest.TavernaServerREST.EventFeed;
+import org.taverna.server.master.utils.InvocationCounter.CallCounted;
+
+/**
+ * Simple REST handler that allows an Atom feed to be served up of events
+ * generated by workflow runs.
+ * 
+ * @author Donal Fellows
+ */
+public class AtomFeed implements EventFeed, UriBuilderFactory,
+               ServletContextAware {
+       /**
+        * The name of a parameter that states what address we should claim 
that the
+        * feed's internally-generated URIs are relative to. If not set, a 
default
+        * will be guessed.
+        */
+       public static final String PREFERRED_URI_PARAM = 
"taverna.preferredUserUri";
+       private EventDAO eventSource;
+       private TavernaServerSupport support;
+       private URI baseURI;
+       private Abdera abdera;
+       private String feedLanguage = "en";
+       private String uuid = randomUUID().toString();
+
+       @Required
+       public void setEventSource(EventDAO eventSource) {
+               this.eventSource = eventSource;
+       }
+
+       @Required
+       public void setSupport(TavernaServerSupport support) {
+               this.support = support;
+       }
+
+       public void setFeedLanguage(String language) {
+               this.feedLanguage = language;
+       }
+
+       public String getFeedLanguage() {
+               return feedLanguage;
+       }
+
+       @Required
+       public void setAbdera(Abdera abdera) {
+               this.abdera = abdera;
+       }
+
+       @Override
+       @CallCounted
+       @RolesAllowed(USER)
+       public Feed getFeed(UriInfo ui) {
+               Feed feed = abdera.getFactory().newFeed();
+               feed.setTitle("events relating to workflow runs").setLanguage(
+                               feedLanguage);
+               String user = support.getPrincipal().toString()
+                               .replaceAll("[^A-Za-z0-9]+", "");
+               feed.setId(format("urn:taverna-server:%s:%s", uuid, user));
+               org.joda.time.DateTime modification = null;
+               for (Event e : eventSource.getEvents(support.getPrincipal())) {
+                       if (modification == null || 
e.getPublished().isAfter(modification))
+                               modification = e.getPublished();
+                       feed.addEntry(e.getEntry(abdera, feedLanguage));
+               }
+               if (modification == null)
+                       feed.setUpdated(new Date());
+               else
+                       feed.setUpdated(modification.toDate());
+               feed.addLink(ui.getAbsolutePath().toASCIIString(), "self");
+               return feed;
+       }
+
+       @Override
+       @CallCounted
+       @RolesAllowed(USER)
+       public Entry getEvent(String id) {
+               return eventSource.getEvent(support.getPrincipal(), 
id).getEntry(
+                               abdera, feedLanguage);
+       }
+
+       @Override
+       public UriBuilder getRunUriBuilder(TavernaRun run) {
+               return 
secure(fromUri(getBaseUriBuilder().path("runs/{uuid}").build(
+                               run.getId())));
+       }
+
+       @Override
+       public UriBuilder getBaseUriBuilder() {
+               return secure(fromUri(baseURI));
+       }
+
+       @Override
+       public String resolve(String uri) {
+               if (uri == null)
+                       return null;
+               return secure(baseURI, uri).toString();
+       }
+
+       @Override
+       public void setServletContext(ServletContext servletContext) {
+               String base = 
servletContext.getInitParameter(PREFERRED_URI_PARAM);
+               if (base == null)
+                       base = servletContext.getContextPath() + "/rest";
+               baseURI = URI.create(base);
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/Event.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/Event.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/Event.java
new file mode 100644
index 0000000..f1a9d62
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/Event.java
@@ -0,0 +1,123 @@
+/*
+ */
+package org.taverna.server.master.notification.atom;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static java.util.UUID.randomUUID;
+
+import java.io.Serializable;
+import java.net.URI;
+import java.util.Date;
+
+import javax.jdo.annotations.Column;
+import javax.jdo.annotations.Index;
+import javax.jdo.annotations.PersistenceCapable;
+import javax.jdo.annotations.Persistent;
+import javax.jdo.annotations.Queries;
+import javax.jdo.annotations.Query;
+
+import org.apache.abdera.Abdera;
+import org.apache.abdera.model.Entry;
+import org.joda.time.DateTime;
+import org.taverna.server.master.utils.UsernamePrincipal;
+
+/**
+ * Parent class of all events that may appear on the feed for a workflow run.
+ * 
+ * @author Donal Fellows
+ */
+@SuppressWarnings("serial")
+@PersistenceCapable(schema = "ATOM", table = "EVENTS")
+@Queries({
+               @Query(name = "eventsForUser", language = "SQL", value = 
"SELECT id FROM ATOM.EVENTS WHERE owner = ? ORDER BY published DESC", 
resultClass = String.class),
+               @Query(name = "eventForUserAndId", language = "SQL", value = 
"SELECT id FROM ATOM.EVENTS WHERE owner = ? AND id = ?", resultClass = 
String.class),
+               @Query(name = "eventsFromBefore", language = "SQL", value = 
"SELECT id FROM ATOM.EVENTS where published < ?", resultClass = String.class) })
+public class Event implements Serializable {
+       @Persistent(primaryKey = "true")
+       @Column(length = 48)
+       private String id;
+       @Persistent
+       private String owner;
+       @Persistent
+       @Index
+       private Date published;
+       @Persistent
+       private String message;
+       @Persistent
+       private String title;
+       @Persistent
+       private String link;
+
+       Event() {
+       }
+
+       /**
+        * Initialise the identity of this event and the point at which it was
+        * published.
+        * 
+        * @param idPrefix
+        *            A prefix for the identity of this event.
+        * @param owner
+        *            Who is the owner of this event.
+        */
+       Event(String idPrefix, URI workflowLink, UsernamePrincipal owner,
+                       String title, String message) {
+               id = idPrefix + "." + randomUUID().toString();
+               published = new Date();
+               this.owner = owner.getName();
+               this.title = title;
+               this.message = message;
+               this.link = workflowLink.toASCIIString();
+       }
+
+       public final String getId() {
+               return id;
+       }
+
+       public final String getOwner() {
+               return owner;
+       }
+
+       public final DateTime getPublished() {
+               return new DateTime(published);
+       }
+
+       public String getMessage() {
+               return message;
+       }
+
+       public String getTitle() {
+               return title;
+       }
+
+       public String getLink() {
+               return link;
+       }
+
+       public Entry getEntry(Abdera abdera, String language) {
+               Entry entry = abdera.getFactory().newEntry();
+               entry.setId(id);
+               entry.setPublished(published);
+               entry.addAuthor(owner).setLanguage(language);
+               entry.setUpdated(published);
+               entry.setTitle(title).setLanguage(language);
+               entry.addLink(link, "related").setTitle("workflow run");
+               entry.setContent(message).setLanguage(language);
+               return entry;
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/EventDAO.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/EventDAO.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/EventDAO.java
new file mode 100644
index 0000000..8bec456
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/EventDAO.java
@@ -0,0 +1,230 @@
+/*
+ */
+package org.taverna.server.master.notification.atom;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static java.lang.Thread.interrupted;
+import static java.lang.Thread.sleep;
+import static java.util.Arrays.asList;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+
+import javax.annotation.Nonnull;
+import javax.annotation.PreDestroy;
+import javax.jdo.annotations.PersistenceAware;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.joda.time.DateTime;
+import org.springframework.beans.factory.annotation.Required;
+import org.taverna.server.master.interfaces.MessageDispatcher;
+import org.taverna.server.master.interfaces.TavernaRun;
+import org.taverna.server.master.interfaces.UriBuilderFactory;
+import org.taverna.server.master.utils.JDOSupport;
+import org.taverna.server.master.utils.UsernamePrincipal;
+
+/**
+ * The database interface that supports the event feed.
+ * 
+ * @author Donal Fellows
+ */
+@PersistenceAware
+public class EventDAO extends JDOSupport<Event> implements MessageDispatcher {
+       public EventDAO() {
+               super(Event.class);
+       }
+
+       @Override
+       public String getName() {
+               return "atom";
+       }
+
+       private Log log = LogFactory.getLog("Taverna.Server.Atom");
+       private UriBuilderFactory ubf;
+       private int expiryAgeDays;
+
+       @Required
+       public void setExpiryAgeDays(int expiryAgeDays) {
+               this.expiryAgeDays = expiryAgeDays;
+       }
+
+       @Required
+       public void setUriBuilderFactory(UriBuilderFactory ubf) {
+               this.ubf = ubf;
+       }
+
+       /**
+        * Get the given user's list of events.
+        * 
+        * @param user
+        *            The identity of the user to get the events for.
+        * @return A copy of the list of events currently known about.
+        */
+       @Nonnull
+       @WithinSingleTransaction
+       public List<Event> getEvents(@Nonnull UsernamePrincipal user) {
+               List<String> ids = eventsForUser(user);
+               if (log.isDebugEnabled())
+                       log.debug("found " + ids.size() + " events for user " + 
user);
+
+               List<Event> result = new ArrayList<>();
+               for (String id : ids) {
+                       Event event = getById(id);
+                       result.add(detach(event));
+               }
+               return result;
+       }
+
+       @SuppressWarnings("unchecked")
+       private List<String> eventsForUser(UsernamePrincipal user) {
+               return (List<String>) namedQuery("eventsForUser").execute(
+                               user.getName());
+       }
+
+       /**
+        * Get a particular event.
+        * 
+        * @param user
+        *            The identity of the user to get the event for.
+        * @param id
+        *            The handle of the event to look up.
+        * @return A copy of the event.
+        */
+       @Nonnull
+       @WithinSingleTransaction
+       public Event getEvent(@Nonnull UsernamePrincipal user, @Nonnull String 
id) {
+               List<String> ids = eventsForUserAndId(user, id);
+               if (log.isDebugEnabled())
+                       log.debug("found " + ids.size() + " events for user " + 
user
+                                       + " with id = " + id);
+
+               if (ids.size() != 1)
+                       throw new IllegalArgumentException("no such id");
+               return detach(getById(ids.get(0)));
+       }
+
+       @SuppressWarnings("unchecked")
+       private List<String> eventsForUserAndId(UsernamePrincipal user, String 
id) {
+               return (List<String>) namedQuery("eventForUserAndId").execute(
+                               user.getName(), id);
+       }
+
+       /**
+        * Delete a particular event.
+        * 
+        * @param id
+        *            The identifier of the event to delete.
+        */
+       @WithinSingleTransaction
+       public void deleteEventById(@Nonnull String id) {
+               delete(getById(id));
+       }
+
+       /**
+        * Delete all events that have expired.
+        */
+       @WithinSingleTransaction
+       public void deleteExpiredEvents() {
+               Date death = new DateTime().plusDays(-expiryAgeDays).toDate();
+               death = new Timestamp(death.getTime()); // UGLY SQL HACK
+
+               List<String> ids = eventsFromBefore(death);
+               if (log.isDebugEnabled() && !ids.isEmpty())
+                       log.debug("found " + ids.size()
+                                       + " events to be squelched (older than 
" + death + ")");
+
+               for (String id : ids)
+                       delete(getById(id));
+       }
+
+       @SuppressWarnings("unchecked")
+       private List<String> eventsFromBefore(Date death) {
+               return (List<String>) 
namedQuery("eventsFromBefore").execute(death);
+       }
+
+       @Override
+       public boolean isAvailable() {
+               return true;
+       }
+
+       private BlockingQueue<Event> insertQueue = new ArrayBlockingQueue<>(16);
+
+       @Override
+       public void dispatch(TavernaRun originator, String messageSubject,
+                       String messageContent, String targetParameter) throws 
Exception {
+               insertQueue.put(new Event("finish", 
ubf.getRunUriBuilder(originator)
+                               .build(), 
originator.getSecurityContext().getOwner(),
+                               messageSubject, messageContent));
+       }
+
+       public void started(TavernaRun originator, String messageSubject,
+                       String messageContent) throws InterruptedException {
+               insertQueue.put(new Event("start", 
ubf.getRunUriBuilder(originator)
+                               .build(), 
originator.getSecurityContext().getOwner(),
+                               messageSubject, messageContent));
+       }
+
+       private Thread eventDaemon;
+       private boolean shuttingDown = false;
+
+       @Required
+       public void setSelf(final EventDAO dao) {
+               eventDaemon = new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       while (!shuttingDown && !interrupted()) 
{
+                                               transferEvents(dao, new 
ArrayList<Event>(
+                                                               
asList(insertQueue.take())));
+                                               sleep(5000);
+                                       }
+                               } catch (InterruptedException e) {
+                               } finally {
+                                       transferEvents(dao, new 
ArrayList<Event>());
+                               }
+                       }
+               }, "ATOM event daemon");
+               eventDaemon.setContextClassLoader(null);
+               eventDaemon.setDaemon(true);
+               eventDaemon.start();
+       }
+
+       private void transferEvents(EventDAO dao, List<Event> e) {
+               insertQueue.drainTo(e);
+               dao.storeEvents(e);
+       }
+
+       @PreDestroy
+       void stopDaemon() {
+               shuttingDown = true;
+               if (eventDaemon != null)
+                       eventDaemon.interrupt();
+       }
+
+       @WithinSingleTransaction
+       protected void storeEvents(List<Event> events) {
+               for (Event e : events)
+                       persist(e);
+               log.info("stored " + events.size() + " notification events");
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/package-info.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/package-info.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/package-info.java
new file mode 100644
index 0000000..0a1a52f
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/atom/package-info.java
@@ -0,0 +1,42 @@
+/*
+ */
+/**
+ * This package contains the Atom feed implementation within Taverna Server.
+ * @author Donal Fellows
+ */
+@XmlSchema(namespace = FEED, elementFormDefault = QUALIFIED, 
attributeFormDefault = QUALIFIED, xmlns = {
+               @XmlNs(prefix = "xlink", namespaceURI = XLINK),
+               @XmlNs(prefix = "ts", namespaceURI = SERVER),
+               @XmlNs(prefix = "ts-rest", namespaceURI = SERVER_REST),
+               @XmlNs(prefix = "ts-soap", namespaceURI = SERVER_SOAP),
+               @XmlNs(prefix = "feed", namespaceURI = FEED),
+               @XmlNs(prefix = "admin", namespaceURI = ADMIN) })
+package org.taverna.server.master.notification.atom;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static javax.xml.bind.annotation.XmlNsForm.QUALIFIED;
+import static org.taverna.server.master.common.Namespaces.ADMIN;
+import static org.taverna.server.master.common.Namespaces.FEED;
+import static org.taverna.server.master.common.Namespaces.SERVER;
+import static org.taverna.server.master.common.Namespaces.SERVER_REST;
+import static org.taverna.server.master.common.Namespaces.SERVER_SOAP;
+import static org.taverna.server.master.common.Namespaces.XLINK;
+
+import javax.xml.bind.annotation.XmlNs;
+import javax.xml.bind.annotation.XmlSchema;
+

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/package-info.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/package-info.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/package-info.java
new file mode 100644
index 0000000..43335cf
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/notification/package-info.java
@@ -0,0 +1,23 @@
+/*
+ */
+/**
+ * The notification fabric and implementations of notification dispatchers
+ * that support subscription.
+ */
+package org.taverna.server.master.notification;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/package-info.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/package-info.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/package-info.java
new file mode 100644
index 0000000..d912ac8
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/package-info.java
@@ -0,0 +1,24 @@
+/*
+ */
+/**
+ * The core of the implementation of Taverna Server, including the
+ * implementations of the SOAP and REST interfaces.
+ */
+package org.taverna.server.master;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/ContentTypes.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/ContentTypes.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/ContentTypes.java
new file mode 100644
index 0000000..d9aef82
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/ContentTypes.java
@@ -0,0 +1,41 @@
+/*
+ */
+package org.taverna.server.master.rest;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static javax.ws.rs.core.MediaType.APPLICATION_ATOM_XML;
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
+import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM;
+import static javax.ws.rs.core.MediaType.APPLICATION_XML;
+import static javax.ws.rs.core.MediaType.TEXT_PLAIN;
+
+/**
+ * Miscellaneous content type constants.
+ * 
+ * @author Donal Fellows
+ */
+interface ContentTypes {
+       static final String URI_LIST = "text/uri-list";
+       static final String ZIP = "application/zip";
+       static final String TEXT = TEXT_PLAIN;
+       static final String XML = APPLICATION_XML;
+       static final String JSON = APPLICATION_JSON;
+       static final String BYTES = APPLICATION_OCTET_STREAM;
+       static final String ATOM = APPLICATION_ATOM_XML;
+       static final String ROBUNDLE = "application/vnd.wf4ever.robundle+zip";
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/DirectoryContents.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/DirectoryContents.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/DirectoryContents.java
new file mode 100644
index 0000000..42d4b0e
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/DirectoryContents.java
@@ -0,0 +1,74 @@
+/*
+ */
+package org.taverna.server.master.rest;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static org.taverna.server.master.common.Uri.secure;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+import javax.xml.bind.annotation.XmlElementRef;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlSeeAlso;
+import javax.xml.bind.annotation.XmlType;
+
+import org.taverna.server.master.common.DirEntryReference;
+import org.taverna.server.master.interfaces.DirectoryEntry;
+
+/**
+ * The result of a RESTful operation to list the contents of a directory. Done
+ * with JAXB.
+ * 
+ * @author Donal Fellows
+ */
+@XmlRootElement
+@XmlType(name = "DirectoryContents")
+@XmlSeeAlso(MakeOrUpdateDirEntry.class)
+public class DirectoryContents {
+       /**
+        * The contents of the directory.
+        */
+       @XmlElementRef
+       public List<DirEntryReference> contents;
+
+       /**
+        * Make an empty directory description. Required for JAXB.
+        */
+       public DirectoryContents() {
+               contents = new ArrayList<>();
+       }
+
+       /**
+        * Make a directory description.
+        * 
+        * @param ui
+        *            The factory for URIs.
+        * @param collection
+        *            The real directory contents that we are to describe.
+        */
+       public DirectoryContents(UriInfo ui, Collection<DirectoryEntry> 
collection) {
+               contents = new ArrayList<>();
+               UriBuilder ub = secure(ui).path("{filename}");
+               for (DirectoryEntry e : collection)
+                       contents.add(DirEntryReference.newInstance(ub, e));
+       }
+}

http://git-wip-us.apache.org/repos/asf/incubator-taverna-server/blob/00397eff/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/FileSegment.java
----------------------------------------------------------------------
diff --git 
a/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/FileSegment.java
 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/FileSegment.java
new file mode 100644
index 0000000..6b7aaff
--- /dev/null
+++ 
b/taverna-server-webapp/src/main/java/org/apache/taverna/server/master/rest/FileSegment.java
@@ -0,0 +1,91 @@
+/*
+ */
+package org.taverna.server.master.rest;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import static javax.ws.rs.core.Response.ok;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import org.taverna.server.master.exceptions.FilesystemAccessException;
+import org.taverna.server.master.interfaces.File;
+
+/**
+ * Representation of a segment of a file to be read by JAX-RS.
+ * 
+ * @author Donal Fellows
+ */
+public class FileSegment {
+       /** The file to read a segment of. */
+       public final File file;
+       /** The offset of the first byte of the segment to read. */
+       public Integer from;
+       /** The offset of the first byte after the segment to read. */
+       public Integer to;
+
+       /**
+        * Parse the HTTP Range header and determine what exact range of the 
file to
+        * read.
+        * 
+        * @param f
+        *            The file this refers to
+        * @param range
+        *            The content of the Range header.
+        * @throws FilesystemAccessException
+        *             If we can't determine the length of the file (shouldn't
+        *             happen).
+        */
+       public FileSegment(File f, String range) throws 
FilesystemAccessException {
+               file = f;
+               Matcher m = 
Pattern.compile("^\\s*bytes=(\\d*)-(\\d*)\\s*$").matcher(
+                               range);
+               if (m.matches()) {
+                       if (!m.group(1).isEmpty())
+                               from = Integer.valueOf(m.group(1));
+                       if (!m.group(2).isEmpty())
+                               to = Integer.valueOf(m.group(2)) + 1;
+                       int size = (int) f.getSize();
+                       if (from == null) {
+                               from = size - to;
+                               to = size;
+                       } else if (to == null)
+                               to = size;
+                       else if (to > size)
+                               to = size;
+               }
+       }
+
+       /**
+        * Convert to a response, as per RFC 2616.
+        * 
+        * @param type
+        *            The expected type of the data.
+        * @return A JAX-RS response.
+        */
+       public Response toResponse(MediaType type) {
+               if (from == null && to == null)
+                       return ok(file).type(type).build();
+               if (from >= to)
+                       return ok("Requested range not 
satisfiable").status(416).build();
+               return ok(this).status(206).type(type).build();
+       }
+}
\ No newline at end of file


Reply via email to