Super Dev Mode: replace public event logging API

- RecompileListener is deprecated.
- JobChangeListener is the new public API.

The new listener takes a JobEvent, which is designed
so we can add new public properties easily. JobEvent
replaces the old Progress class. I added property
validation and one more status for a failed compile.

Command line interface changes:

- Check earlier if a supplied module name is invalid.

HTTP API changes:

- For /progress, I renamed "stepMessage" field to "message",
and removed finishedSteps and totalSteps.

I removed the step information since it's unused and
will be tricky to calculate. We can add it back later
if needed, but compiles normally won't take that long.

ModuleDefSchema: extracted a method to check if a property
name is valid.

Change-Id: Ic69984c7090ca6458f3e467d6b431cc1e4904000
Review-Link: https://gwt-review.googlesource.com/#/c/9084/
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
index c23fecd..4ba3b9a 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/CodeServer.java
@@ -118,14 +118,14 @@
     TreeLogger startupLogger = topLogger.branch(Type.INFO, "Super Dev Mode starting up");
     OutboxTable outboxes = makeOutboxes(options, startupLogger);
 
-    ProgressTable progressTable = new ProgressTable();
-    JobRunner runner = new JobRunner(progressTable, outboxes);
+    JobEventTable eventTable = new JobEventTable();
+    JobRunner runner = new JobRunner(eventTable, outboxes);
 
     JsonExporter exporter = new JsonExporter(options, outboxes);
 
     SourceHandler sourceHandler = new SourceHandler(outboxes, exporter);
     WebServer webServer = new WebServer(sourceHandler, exporter, outboxes,
-        runner, progressTable, options.getBindAddress(), options.getPort());
+        runner, eventTable, options.getBindAddress(), options.getPort());
     webServer.start(topLogger);
 
     return webServer;
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java
index 586d3ca..845215e 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Job.java
@@ -17,7 +17,9 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.TreeLogger.Type;
-import com.google.gwt.dev.codeserver.Progress.Status;
+import com.google.gwt.dev.cfg.ModuleDef;
+import com.google.gwt.dev.codeserver.JobEvent.Status;
+import com.google.gwt.thirdparty.guava.common.base.Preconditions;
 import com.google.gwt.thirdparty.guava.common.collect.ImmutableSortedMap;
 import com.google.gwt.thirdparty.guava.common.util.concurrent.Futures;
 import com.google.gwt.thirdparty.guava.common.util.concurrent.ListenableFuture;
@@ -32,7 +34,7 @@
  * A request for Super Dev Mode to compile something.
  *
  * <p>Each job has a lifecycle where it goes through up to four states. See
- * {@link Progress.Status}.
+ * {@link JobEvent.Status}.
  *
  * <p>Jobs are thread-safe.
  */
@@ -58,28 +60,21 @@
 
   private final Outbox outbox;
   private final RecompileListener recompileListener;
+  private final JobChangeListener jobChangeListener;
   private final LogSupplier logSupplier;
 
-  private ProgressTable table; // non-null when submitted
+  private JobEventTable table; // non-null when submitted
 
-  // Progress
-
-  /**
-   * The number of calls to {@link #onCompilerProgress}.
-   */
-  private int finishedSteps = 0;
-
-  /**
-   * The estimated total number of calls to {@link #onCompilerProgress}.
-   */
-  private int totalSteps = -1; // non-negative after the compile has started
+  // Miscellaneous
 
   /**
    * The id to report to the recompile listener.
    */
   private int compileId = -1; // non-negative after the compile has started
 
-  private Exception recompileListenerFailure;
+  private CompileDir compileDir; // non-null after the compile has started
+
+  private Exception listenerFailure;
 
   /**
    * Creates a job to update an outbox.
@@ -88,21 +83,27 @@
    * @param parentLogger  The parent of the logger that will be used for this job.
    */
   Job(Outbox box, Map<String, String> bindingProperties,
-      TreeLogger parentLogger, RecompileListener recompileListener) {
+      TreeLogger parentLogger, RecompileListener recompileListener,
+      JobChangeListener jobChangeListener) {
     this.id = chooseNextId(box);
     this.outbox = box;
     this.inputModuleName = box.getInputModuleName();
     // TODO: we will use the binding properties to find or create the outbox,
     // then take binding properties from the outbox here.
     this.bindingProperties = ImmutableSortedMap.copyOf(bindingProperties);
-    this.recompileListener = recompileListener;
+    this.recompileListener = Preconditions.checkNotNull(recompileListener);
+    this.jobChangeListener = Preconditions.checkNotNull(jobChangeListener);
     this.logSupplier = new LogSupplier(parentLogger, id);
   }
 
+  static boolean isValidJobId(String id) {
+    return ModuleDef.isValidModuleName(id);
+  }
+
   private static String chooseNextId(Outbox box) {
     String prefix = box.getId();
     prefixToNextId.putIfAbsent(prefix, new AtomicInteger(0));
-    return prefix + "-" + prefixToNextId.get(prefix).getAndIncrement();
+    return prefix + "_" + prefixToNextId.get(prefix).getAndIncrement();
   }
 
   /**
@@ -157,8 +158,8 @@
     return result;
   }
 
-  Exception getRecompileListenerFailure() {
-    return recompileListenerFailure;
+  Exception getListenerFailure() {
+    return listenerFailure;
   }
 
   // === state transitions ===
@@ -180,49 +181,43 @@
    * Starts sending updates to the JobTable.
    * @throws IllegalStateException if the job was already started.
    */
-  synchronized void onSubmitted(ProgressTable table) {
+  synchronized void onSubmitted(JobEventTable table) {
     if (wasSubmitted()) {
       throw new IllegalStateException("compile job has already started: " + id);
     }
     this.table = table;
-    table.publish(new Progress(this, Status.WAITING), getLogger());
+    table.publish(makeEvent(Status.WAITING), getLogger());
   }
 
   /**
    * Reports that we started to compile the job.
    */
-  synchronized void onStarted(int totalSteps, int compileId, CompileDir compileDir) {
-    if (totalSteps < 0) {
-      throw new IllegalArgumentException("totalSteps should not be negative: " + totalSteps);
-    }
+  synchronized void onStarted(int compileId, CompileDir compileDir) {
     if (table == null || !table.isActive(this)) {
       throw new IllegalStateException("compile job is not active: " + id);
     }
-    if (this.totalSteps >= 0) {
-      throw new IllegalStateException("onStarted already called for " + id);
-    }
-    this.totalSteps = totalSteps;
     this.compileId = compileId;
+    this.compileDir = compileDir;
 
     try {
       recompileListener.startedCompile(inputModuleName, compileId, compileDir);
     } catch (Exception e) {
       getLogger().log(TreeLogger.Type.WARN, "recompile listener threw exception", e);
-      recompileListenerFailure = e;
+      listenerFailure = e;
     }
+
+    publish(makeEvent(Status.COMPILING));
   }
 
   /**
-   * Reports that this job has made progress.
+   * Reports that this job has made progress while compiling.
    * @throws IllegalStateException if the job is not running.
    */
-  synchronized void onCompilerProgress(String stepMessage) {
-    if (table == null || !table.isActive(this)) {
-      throw new IllegalStateException("compile job is not active: " + id);
+  synchronized void onProgress(String stepMessage) {
+    if (table == null || table.getPublishedEvent(this).getStatus() != Status.COMPILING) {
+      throw new IllegalStateException("onProgress called for a job that isn't compiling: " + id);
     }
-    finishedSteps++;
-    table.publish(new Progress.Compiling(this, finishedSteps, totalSteps, stepMessage),
-        getLogger());
+    publish(makeEvent(Status.COMPILING, stepMessage));
   }
 
   /**
@@ -235,20 +230,20 @@
     }
 
     // Report that we finished unless the listener messed up already.
-    if (recompileListenerFailure == null) {
+    if (listenerFailure == null) {
       try {
         recompileListener.finishedCompile(inputModuleName, compileId, newResult.isOk());
       } catch (Exception e) {
         getLogger().log(TreeLogger.Type.WARN, "recompile listener threw exception", e);
-        recompileListenerFailure = e;
+        listenerFailure = e;
       }
     }
 
     result.set(newResult);
     if (newResult.isOk()) {
-      table.publish(new Progress(this, Status.SERVING), getLogger());
+      publish(makeEvent(Status.SERVING));
     } else {
-      table.publish(new Progress(this, Status.GONE), getLogger());
+      publish(makeEvent(Status.ERROR));
     }
   }
 
@@ -259,7 +254,37 @@
     if (table == null || !table.isActive(this)) {
       throw new IllegalStateException("compile job is not active: " + id);
     }
-    table.publish(new Progress(this, Status.GONE), getLogger());
+    publish(makeEvent(Status.GONE));
+  }
+
+  private JobEvent makeEvent(Status status) {
+    return makeEvent(status, null);
+  }
+
+  private JobEvent makeEvent(Status status, String message) {
+    JobEvent.Builder out = new JobEvent.Builder();
+    out.setJobId(getId());
+    out.setInputModuleName(getInputModuleName());
+    out.setBindings(getBindingProperties());
+    out.setStatus(status);
+    out.setMessage(message);
+    out.setCompileDir(compileDir);
+    return out.build();
+  }
+
+  /**
+   * Makes an event visible externally.
+   */
+  private void publish(JobEvent event) {
+    if (listenerFailure == null) {
+      try {
+        jobChangeListener.onJobChange(event);
+      } catch (Exception e) {
+        getLogger().log(Type.WARN, "JobChangeListener threw exception", e);
+        listenerFailure = e;
+      }
+    }
+    table.publish(event, getLogger());
   }
 
   /**
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/JobChangeListener.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobChangeListener.java
new file mode 100644
index 0000000..7ad8f03
--- /dev/null
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobChangeListener.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed 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.
+ */
+package com.google.gwt.dev.codeserver;
+
+/**
+ * A callback for receiving events when a GWT compile job changes status.
+ *
+ * This interface replaces {@link RecompileListener}.
+ */
+public interface JobChangeListener {
+  void onJobChange(JobEvent event);
+
+  /**
+   * A listener that does nothing.
+   */
+  static final JobChangeListener NONE = new JobChangeListener() {
+    @Override
+    public void onJobChange(JobEvent event) {
+    }
+  };
+}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/JobEvent.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobEvent.java
new file mode 100644
index 0000000..915b248
--- /dev/null
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobEvent.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2014 Google Inc.
+ *
+ * Licensed 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.
+ */
+package com.google.gwt.dev.codeserver;
+
+import com.google.gwt.dev.cfg.ModuleDef;
+import com.google.gwt.dev.cfg.ModuleDefSchema;
+import com.google.gwt.thirdparty.guava.common.base.Preconditions;
+import com.google.gwt.thirdparty.guava.common.collect.ImmutableMap;
+import com.google.gwt.thirdparty.guava.common.collect.ImmutableSortedMap;
+
+import java.util.Map;
+import java.util.SortedMap;
+
+/**
+ * The status of a compile job submitted to Super Dev Mode.
+ *
+ * <p>JobEvent objects are deeply immutable, though they describe a Job that changes.
+ */
+public final class JobEvent {
+
+  private final String jobId;
+
+  private final String inputModuleName;
+  private final ImmutableSortedMap<String, String> bindings;
+  private final Status status;
+  private final String message;
+
+  private final CompileDir compileDir;
+
+  private JobEvent(Builder builder) {
+    this.jobId = Preconditions.checkNotNull(builder.jobId);
+    this.inputModuleName = Preconditions.checkNotNull(builder.inputModuleName);
+    this.bindings = ImmutableSortedMap.copyOf(builder.bindings);
+    this.status = Preconditions.checkNotNull(builder.status);
+    this.message = builder.message == null ? status.defaultMessage : builder.message;
+
+    // The following fields may be null.
+    this.compileDir = builder.compileDir;
+
+    // Any new fields added should allow nulls for backward compatibility.
+  }
+
+  /**
+   * The id of the job being compiled. Unique within the same CodeServer process.
+   * This should be considered an opaque string.
+   */
+  public String getJobId() {
+    return jobId;
+  }
+
+  /**
+   * The module name sent to the GWT compiler to start the compile.
+   */
+  public String getInputModuleName() {
+    return inputModuleName;
+  }
+
+  /**
+   * The binding properties sent to the GWT compiler.
+   */
+  public SortedMap<String, String> getBindings() {
+    // Can't return ImmutableSortedMap here because it's repackaged and this is a public API.
+    return bindings;
+  }
+
+  /**
+   * The last reported status of the job.
+   */
+  public Status getStatus() {
+    return status;
+  }
+
+  /**
+   * Returns a line of text describing the job's current status.
+   */
+  public String getMessage() {
+    return message;
+  }
+
+  /**
+   * Returns the directory where the GWT module is being compiled, or null if not available.
+   * (Not available for jobs that are WAITING.)
+   */
+  public CompileDir getCompileDir() {
+    return compileDir;
+  }
+
+  /**
+   * Defines the lifecycle of a job.
+   */
+  public static enum Status {
+    WAITING("waiting", "Waiting for the compiler to start"),
+    COMPILING("compiling", "Compiling"),
+    SERVING("serving", "Compiled output is ready"),
+    GONE("gone", "Compiled output is no longer available"),
+    ERROR("error", "Compile failed with an error");
+
+    final String jsonName;
+    final String defaultMessage;
+
+    Status(String jsonName, String defaultMessage) {
+      this.jsonName = jsonName;
+      this.defaultMessage = defaultMessage;
+    }
+  }
+
+  /**
+   * Creates a JobEvent.
+   * This is public to allow external tests of code that implements {@link JobChangeListener}.
+   * Normally all JobEvents are created in the code server.
+   */
+  public static class Builder {
+    private String jobId;
+
+    private String inputModuleName;
+    private Map<String, String> bindings = ImmutableMap.of();
+    private Status status;
+    private String message;
+    private CompileDir compileDir;
+
+    /**
+     * A unique id for this job. Required.
+     */
+    public void setJobId(String jobId) {
+      Preconditions.checkArgument(Job.isValidJobId(jobId), "invalid job id: " + jobId);
+      this.jobId = jobId;
+    }
+
+    /**
+     * The name of the module as passed to the compiler. Required.
+     */
+    public void setInputModuleName(String inputModuleName) {
+      Preconditions.checkArgument(ModuleDef.isValidModuleName(inputModuleName),
+          "invalid module name: " + jobId);
+      this.inputModuleName = inputModuleName;
+    }
+
+    /**
+     * The bindings passed to the compiler.
+     * Optional, but may not be null. (Defaults to the empty map.)
+     */
+    public void setBindings(Map<String, String> bindings) {
+      for (String name : bindings.keySet()) {
+        if (!ModuleDefSchema.isValidPropertyName(name)) {
+          throw new IllegalArgumentException("invalid property name: " + name);
+        }
+      }
+      this.bindings = bindings;
+    }
+
+    /**
+     * The job's current status. Required.
+     */
+    public void setStatus(Status status) {
+      this.status = status;
+    }
+
+    /**
+     * A message to describing the job's current state.
+     * It should be a single line of text.
+     * Optional. If null, a default message will be used.
+     */
+    public void setMessage(String message) {
+      if (message != null) {
+        Preconditions.checkArgument(!message.contains("\n"),
+            "JobEvent messages should be a single line of text");
+      }
+      this.message = message;
+    }
+
+    /**
+     * The directory where the GWT compiler will write its output.
+     * Optional. (Not available until the compile starts.)
+     */
+    public void setCompileDir(CompileDir compileDir) {
+      this.compileDir = compileDir;
+    }
+
+    public JobEvent build() {
+      return new JobEvent(this);
+    }
+  }
+}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobEventTable.java
similarity index 60%
rename from dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java
rename to dev/codeserver/java/com/google/gwt/dev/codeserver/JobEventTable.java
index 4711908..83bf47b 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/ProgressTable.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobEventTable.java
@@ -17,7 +17,7 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.TreeLogger.Type;
-import com.google.gwt.dev.codeserver.Progress.Status;
+import com.google.gwt.dev.codeserver.JobEvent.Status;
 import com.google.gwt.thirdparty.guava.common.collect.ImmutableList;
 import com.google.gwt.thirdparty.guava.common.collect.Maps;
 
@@ -26,14 +26,15 @@
 import java.util.Set;
 
 /**
- * Contains the progress of all the jobs that have been submitted to the JobRunner.
+ * Contains the current status of each {@link Job}.
+ * (That is, the most recently reported event.)
  */
-class ProgressTable {
+class JobEventTable {
 
   /**
-   * The progress of each known job, by job id.
+   * The most recent event sent by each job.
    */
-  private final Map<String, Progress> progressById = Maps.newHashMap();
+  private final Map<String, JobEvent> eventsByJobId = Maps.newHashMap();
 
   /**
    * A set of submitted job ids that are still active, in the order they were submitted.
@@ -48,36 +49,47 @@
   private final Set<String> compilingJobIds = new LinkedHashSet<String>();
 
   /**
+   * Returns the event that's currently published for the given job.
+   */
+  synchronized JobEvent getPublishedEvent(Job job) {
+    return eventsByJobId.get(job.getId());
+  }
+
+  /**
    * Publishes the progress of a job after it changed. (This replaces any previous progress.)
    */
-  synchronized void publish(Progress progress, TreeLogger logger) {
-    String id = progress.jobId;
+  synchronized void publish(JobEvent event, TreeLogger logger) {
+    String id = event.getJobId();
 
-    progressById.put(id, progress);
+    eventsByJobId.put(id, event);
 
     // Update indexes
 
-    if (progress.isActive()) {
+    if (isActive(event.getStatus())) {
       activeJobIds.add(id);
     } else {
       activeJobIds.remove(id);
     }
 
-    if (progress.status == Status.COMPILING) {
+    if (event.getStatus() == Status.COMPILING) {
       compilingJobIds.add(id);
       assert compilingJobIds.size() <= 1;
     } else {
       compilingJobIds.remove(id);
     }
 
-    logger.log(Type.TRACE, "job's progress set to " + progress.status + ": " + id);
+    logger.log(Type.TRACE, "job's progress set to " + event.getStatus() + ": " + id);
+  }
+
+  private static boolean isActive(Status status) {
+    return status == Status.WAITING || status == Status.COMPILING || status == Status.SERVING;
   }
 
   /**
    * Returns true if the job's status was ever published.
    */
   synchronized boolean wasSubmitted(Job job) {
-    return progressById.containsKey(job.getId());
+    return eventsByJobId.containsKey(job.getId());
   }
 
   synchronized boolean isActive(Job job) {
@@ -85,27 +97,29 @@
   }
 
   /**
-   * Returns the progress of the job that's currently being compiled, or null if idle.
+   * Returns an event indicating the current status of the job that's currently being compiled,
+   * or null if idle.
    */
-  synchronized Progress getProgressForCompilingJob() {
+  synchronized JobEvent getCompilingJobEvent() {
     if (compilingJobIds.isEmpty()) {
       return null;
     }
 
     String id = compilingJobIds.iterator().next();
-    Progress progress = progressById.get(id);
-    assert progress != null;
-    return progress;
+    JobEvent event = eventsByJobId.get(id);
+    assert event != null;
+    return event;
   }
 
   /**
-   * Returns the progress of all active jobs, in the order submitted.
+   * Returns an event indicating the current status of each active job, in the order they were
+   * submitted.
    * TODO: hook this up.
    */
-  synchronized ImmutableList<Progress> getProgressForActiveJobs() {
-    ImmutableList.Builder<Progress> builder = ImmutableList.builder();
+  synchronized ImmutableList<JobEvent> getActiveEvents() {
+    ImmutableList.Builder<JobEvent> builder = ImmutableList.builder();
     for (String id : activeJobIds) {
-      Progress p = progressById.get(id);
+      JobEvent p = eventsByJobId.get(id);
       assert p != null;
       builder.add(p);
     }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java
index 923b528..5ae4694 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JobRunner.java
@@ -29,11 +29,11 @@
  * <p>JobRunners are thread-safe.
  */
 public class JobRunner {
-  private final ProgressTable table;
+  private final JobEventTable table;
   private final OutboxTable outboxes;
   private final ExecutorService executor = Executors.newSingleThreadExecutor();
 
-  JobRunner(ProgressTable table, OutboxTable outboxes) {
+  JobRunner(JobEventTable table, OutboxTable outboxes) {
     this.table = table;
     this.outboxes = outboxes;
   }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/JsonExporter.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/JsonExporter.java
index a7cb781..5f645c8 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/JsonExporter.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/JsonExporter.java
@@ -54,19 +54,14 @@
   /**
    * Creates the response to a /progress request.
    */
-  JsonObject exportProgressResponse(Progress progress) {
+  JsonObject exportProgressResponse(JobEvent progress) {
     // TODO: upgrade for multiple compiles and finalize API for 2.7.
     JsonObject out = new JsonObject();
-    out.put("jobId", progress.jobId);
-    out.put("status", progress.status.jsonName);
-    out.put("inputModule", progress.inputModuleName);
-    out.put("bindings", exportMap(progress.bindings));
-    if (progress instanceof Progress.Compiling) {
-      Progress.Compiling compiling = (Progress.Compiling) progress;
-      out.put("finishedSteps", compiling.finishedSteps);
-      out.put("totalSteps", compiling.totalSteps);
-      out.put("stepMessage", compiling.stepMessage);
-    }
+    out.put("jobId", progress.getJobId());
+    out.put("status", progress.getStatus().jsonName);
+    out.put("message", progress.getMessage());
+    out.put("inputModule", progress.getInputModuleName());
+    out.put("bindings", exportMap(progress.getBindings()));
     return out;
   }
 
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Options.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Options.java
index 7a1c789..3d4f6c5 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Options.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Options.java
@@ -18,6 +18,7 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.dev.ArgProcessorBase;
+import com.google.gwt.dev.cfg.ModuleDef;
 import com.google.gwt.dev.util.arg.ArgHandlerJsInteropMode;
 import com.google.gwt.dev.util.arg.ArgHandlerLogLevel;
 import com.google.gwt.dev.util.arg.ArgHandlerSourceLevel;
@@ -56,7 +57,10 @@
   private String bindAddress = "127.0.0.1";
   private String preferredHost = "localhost";
   private int port = 9876;
+
   private RecompileListener recompileListener = RecompileListener.NONE;
+  private JobChangeListener jobChangeListener = JobChangeListener.NONE;
+
   private TreeLogger.Type logLevel = TreeLogger.Type.INFO;
   // Use the same default as the GWT compiler.
   private SourceLevel sourceLevel = SourceLevel.DEFAULT_SOURCE_LEVEL;
@@ -102,7 +106,10 @@
   /**
    * A Java application that embeds Super Dev Mode can use this hook to find out
    * when compiles start and end.
+   *
+   * @deprecated replaced by {@link #setJobChangeListener}
    */
+  @Deprecated
   public void setRecompileListener(RecompileListener recompileListener) {
     this.recompileListener = recompileListener;
   }
@@ -112,6 +119,20 @@
   }
 
   /**
+   * A Java application that embeds Super Dev Mode can use this hook to find out
+   * when compile jobs change state.
+   *
+   * <p>Replaces {@link #setRecompileListener}
+   */
+  public void setJobChangeListener(JobChangeListener jobChangeListener) {
+    this.jobChangeListener = jobChangeListener;
+  }
+
+  JobChangeListener getJobChangeListener() {
+    return jobChangeListener;
+  }
+
+  /**
    * The top level of the directory tree where the code server keeps compiler output.
    */
   File getWorkDir() {
@@ -626,6 +647,10 @@
 
     @Override
     public boolean addExtraArg(String arg) {
+      if (!ModuleDef.isValidModuleName(arg)) {
+        System.err.println("Invalid module name: '" + arg + "'");
+        return false;
+      }
       moduleNames.add(arg);
       return true;
     }
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Outbox.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Outbox.java
index f2275f2..be501e6 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Outbox.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Outbox.java
@@ -18,7 +18,9 @@
 
 import com.google.gwt.core.ext.TreeLogger;
 import com.google.gwt.core.ext.UnableToCompleteException;
+import com.google.gwt.dev.cfg.ModuleDef;
 import com.google.gwt.dev.codeserver.Job.Result;
+import com.google.gwt.thirdparty.guava.common.base.Preconditions;
 
 import java.io.BufferedInputStream;
 import java.io.File;
@@ -51,12 +53,17 @@
 
   Outbox(String id, Recompiler recompiler, Options options, TreeLogger logger)
       throws UnableToCompleteException {
+    Preconditions.checkArgument(isValidOutboxId(id));
     this.id = id;
     this.recompiler = recompiler;
     this.options = options;
     maybePrecompile(logger);
   }
 
+  private boolean isValidOutboxId(String id) {
+    return ModuleDef.isValidModuleName(id);
+  }
+
   /**
    * A unique id for this outbox. (This should be treated as an opaque string.)
    */
@@ -82,14 +89,16 @@
 
     // Create a dummy job for the first compile.
     // Its progress is not visible externally but will still be logged.
-    ProgressTable dummy = new ProgressTable();
+    JobEventTable dummy = new JobEventTable();
     Job job = makeJob(defaultProps, logger);
     job.onSubmitted(dummy);
     publish(recompiler.precompile(job), job);
 
     if (options.isCompileTest()) {
+
       // Listener errors are fatal in compile tests
-      Throwable error = job.getRecompileListenerFailure();
+
+      Throwable error = job.getListenerFailure();
       if (error != null) {
         UnableToCompleteException e = new UnableToCompleteException();
         e.initCause(error);
@@ -102,7 +111,8 @@
    * Creates a Job whose output will be saved in this outbox.
    */
   Job makeJob(Map<String, String> bindingProperties, TreeLogger parentLogger) {
-    return new Job(this, bindingProperties, parentLogger, options.getRecompileListener());
+    return new Job(this, bindingProperties, parentLogger,
+        options.getRecompileListener(), options.getJobChangeListener());
   }
 
   /**
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java
deleted file mode 100644
index b84c73f..0000000
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Progress.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Copyright 2014 Google Inc.
- *
- * Licensed 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.
- */
-package com.google.gwt.dev.codeserver;
-
-import com.google.gwt.thirdparty.guava.common.collect.ImmutableSortedMap;
-
-/**
- * A snapshot of a {@link Job}'s current state, for progress dialogs.
- */
-class Progress {
-
-  /**
-   * The id of the job being compiled. (Unique within the same CodeServer process.)
-   */
-  final String jobId;
-
-  final String inputModuleName;
-  final ImmutableSortedMap<String, String> bindings;
-  final Status status;
-
-  Progress(Job job, Status status) {
-    this.jobId = job.getId();
-    this.inputModuleName = job.getInputModuleName();
-    this.bindings = job.getBindingProperties();
-    this.status = status;
-  }
-
-  /**
-   * Returns true if the job's progress should be shown in the progress view.
-   * (For jobs that are GONE, their status is only available by request.)
-   */
-  public boolean isActive() {
-    return status == Status.WAITING || status == Status.COMPILING || status == Status.SERVING;
-  }
-
-  /**
-   * Defines the lifecycle of a job.
-   */
-  static enum Status {
-    WAITING("waiting"),
-    COMPILING("compiling"),
-    SERVING("serving"), // Output is available to HTTP requests
-    GONE("gone"); // Output directory is no longer being served
-
-    final String jsonName;
-
-    Status(String jsonName) {
-      this.jsonName = jsonName;
-    }
-  }
-
-  /**
-   * Returned when a compile is in progress.
-   */
-  static class Compiling extends Progress {
-
-    /**
-     * The number of steps finished, for showing progress.
-     */
-    final int finishedSteps;
-
-    /**
-     * The number of steps total, for showing progress.
-     */
-    final int totalSteps;
-
-    /**
-     * A message about the current step being executed.
-     */
-    final String stepMessage;
-
-    Compiling(Job job, int finishedSteps, int totalSteps, String stepMessage) {
-      super(job, Status.COMPILING);
-      this.finishedSteps = finishedSteps;
-      this.totalSteps = totalSteps;
-      this.stepMessage = stepMessage;
-    }
-  }
-}
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/RecompileListener.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/RecompileListener.java
index b8b688a..e0670fc 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/RecompileListener.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/RecompileListener.java
@@ -18,7 +18,10 @@
 /**
  * A callback interface that can be used to find out when Super Dev Mode starts and
  * finishes its compiles.
+ *
+ * @deprecated replaced by {@link JobChangeListener}.
  */
+@Deprecated
 public interface RecompileListener {
 
   /**
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
index d2ed011..789fb81 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/Recompiler.java
@@ -153,12 +153,11 @@
     CompileDir compileDir = makeCompileDir(compileId, job.getLogger());
     TreeLogger compileLogger = makeCompileLogger(compileDir, job.getLogger());
 
-    int totalSteps = options.shouldCompileIncremental() ? 1 : 2;
-    job.onStarted(totalSteps, compileId, compileDir);
+    job.onStarted(compileId, compileDir);
 
     boolean success;
     if (options.shouldCompileIncremental()) {
-      job.onCompilerProgress("Compiling (incrementally)");
+      job.onProgress("Compiling (incrementally)");
       success = compileIncremental(compileLogger, compileDir);
     } else {
       success = compileMonolithic(compileLogger, compileDir, job);
@@ -261,7 +260,7 @@
   private boolean compileMonolithic(TreeLogger compileLogger, CompileDir compileDir, Job job)
       throws UnableToCompleteException {
 
-    job.onCompilerProgress("Loading modules");
+    job.onProgress("Loading modules");
 
     CompilerOptions loadOptions = new CompilerOptionsImpl(compileDir, inputModuleName, options);
     compilerContext = compilerContextBuilder.options(loadOptions).build();
@@ -281,7 +280,7 @@
       return true;
     }
 
-    job.onCompilerProgress("Compiling");
+    job.onProgress("Compiling");
     // TODO: use speed tracer to get more compiler events?
 
     CompilerOptions runOptions = new CompilerOptionsImpl(compileDir, newModuleName, options);
diff --git a/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java b/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
index bcede76..d5a1e74 100644
--- a/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
+++ b/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
@@ -93,7 +93,7 @@
   private final JsonExporter jsonExporter;
   private final OutboxTable outboxes;
   private final JobRunner runner;
-  private final ProgressTable progressTable;
+  private final JobEventTable eventTable;
 
   private final String bindAddress;
   private final int port;
@@ -101,12 +101,12 @@
   private Server server;
 
   WebServer(SourceHandler handler, JsonExporter jsonExporter, OutboxTable outboxes,
-      JobRunner runner, ProgressTable progressTable, String bindAddress, int port) {
+      JobRunner runner, JobEventTable eventTable, String bindAddress, int port) {
     this.handler = handler;
     this.jsonExporter = jsonExporter;
     this.outboxes = outboxes;
     this.runner = runner;
-    this.progressTable = progressTable;
+    this.eventTable = eventTable;
     this.bindAddress = bindAddress;
     this.port = port;
   }
@@ -249,14 +249,14 @@
     if (target.equals("/progress")) {
       setHandled(request);
       // TODO: return a list of progress objects here, one for each job.
-      Progress progress = progressTable.getProgressForCompilingJob();
+      JobEvent event = eventTable.getCompilingJobEvent();
 
       JsonObject json;
-      if (progress == null) {
+      if (event == null) {
         json = new JsonObject();
         json.put("status", "idle");
       } else {
-        json = jsonExporter.exportProgressResponse(progress);
+        json = jsonExporter.exportProgressResponse(event);
       }
       sendJsonResult(json, request, response, logger);
       return;
diff --git a/dev/core/src/com/google/gwt/dev/cfg/ModuleDefSchema.java b/dev/core/src/com/google/gwt/dev/cfg/ModuleDefSchema.java
index 27beb1d..94b900e 100644
--- a/dev/core/src/com/google/gwt/dev/cfg/ModuleDefSchema.java
+++ b/dev/core/src/com/google/gwt/dev/cfg/ModuleDefSchema.java
@@ -1192,15 +1192,10 @@
     @Override
     public Object convertToArg(Schema schema, int line, String elem,
         String attr, String value) throws UnableToCompleteException {
-      // Ensure each part of the name is valid.
-      //
-      String[] tokens = (value + ". ").split("\\.");
-      for (int i = 0; i < tokens.length - 1; i++) {
-        String token = tokens[i];
-        if (!Util.isValidJavaIdent(token)) {
-          Messages.PROPERTY_NAME_INVALID.log(logger, line, value, null);
-          throw new UnableToCompleteException();
-        }
+
+      if (!isValidPropertyName(value)) {
+        Messages.PROPERTY_NAME_INVALID.log(logger, line, value, null);
+        throw new UnableToCompleteException();
       }
 
       // It is a valid name.
@@ -1209,6 +1204,20 @@
     }
   }
 
+  public static boolean isValidPropertyName(String value) {
+    boolean isValid = true;
+    // Ensure each part of the name is valid.
+    //
+    String[] tokens = (value + ". ").split("\\.");
+    for (int i = 0; i < tokens.length - 1; i++) {
+      String token = tokens[i];
+      if (!Util.isValidJavaIdent(token)) {
+        isValid = false;
+      }
+    }
+    return isValid;
+  }
+
   private static class PropertyValue {
     public final String token;