Skip to content

Commit e74de6f

Browse files
Dim. Reduction and auto-gating on t-SNE plots
1 parent 49b1bc2 commit e74de6f

36 files changed

Lines changed: 6760 additions & 390 deletions

‎app/src/main/java/org/streamflow/bridge/RPaths.java‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,34 @@ public static Path rscript() {
7070
Path exe = Paths.get(envHome, "bin", RSCRIPT);
7171
if (Files.exists(exe)) return exe;
7272
}
73+
// A normal Windows R install ("C:\Program Files\R\R-4.4.2") puts Rscript in bin\ but does NOT
74+
// add it to PATH, so without this the R plugins would be invisible on a machine that has R.
75+
// Prefer the highest version found.
76+
Path sys = newestSystemR();
77+
if (sys != null) return sys;
7378
return Paths.get(RSCRIPT); // fall back to PATH lookup
7479
}
7580

81+
/** Newest {@code <ProgramFiles>\R\R-x.y.z\bin\Rscript.exe} on Windows, or null. */
82+
private static Path newestSystemR() {
83+
if (!IS_WINDOWS) return null;
84+
Path best = null; String bestVer = null;
85+
for (String base : new String[]{System.getenv("ProgramFiles"), System.getenv("ProgramW6432"), "C:\\R"}) {
86+
if (base == null || base.isBlank()) continue;
87+
Path rRoot = Paths.get(base, "R");
88+
if (!Files.isDirectory(rRoot)) continue;
89+
try (java.util.stream.Stream<Path> s = Files.list(rRoot)) {
90+
for (Path dir : s.toList()) {
91+
Path exe = dir.resolve("bin").resolve(RSCRIPT);
92+
if (!Files.exists(exe)) continue;
93+
String ver = dir.getFileName().toString(); // e.g. "R-4.4.2"
94+
if (bestVer == null || ver.compareTo(bestVer) > 0) { bestVer = ver; best = exe; }
95+
}
96+
} catch (java.io.IOException ignored) { }
97+
}
98+
return best;
99+
}
100+
76101
/** Absolute path to streamflow_engine.R. */
77102
public static Path engineScript() {
78103
String override = System.getProperty("streamflow.engine");
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.streamflow.plugins;
2+
3+
import java.nio.file.Path;
4+
import java.util.List;
5+
6+
/**
7+
* A parsed {@code plugin.json}. Plugins are R packages driven through {@code engine/run_plugin.R};
8+
* this is purely the declarative half — what the plugin is called, what R packages it needs, which
9+
* entry function to invoke, and what parameters the UI should collect.
10+
*
11+
* @param dir the plugin directory (contains plugin.json + the entry script)
12+
* @param id stable identifier, e.g. {@code peacoqc}
13+
* @param entryScript script file inside {@code dir}, sourced by run_plugin.R
14+
* @param entryFunction function in that script, called as {@code fn(request)}
15+
* @param rPackages R packages that must be installed for this plugin to run
16+
* @param params parameters the UI collects and passes through in the request JSON
17+
*/
18+
public record PluginManifest(
19+
Path dir,
20+
String id,
21+
String name,
22+
String version,
23+
String description,
24+
String status,
25+
String entryScript,
26+
String entryFunction,
27+
List<String> rPackages,
28+
List<Param> params,
29+
List<String> outputs) {
30+
31+
/** One declared parameter. {@code type} drives which control the Plugins UI renders. */
32+
public record Param(String id, String label, String type, boolean required,
33+
String help, Double min, Double max, Object defaultValue) {}
34+
35+
/** Convenience: the entry script's absolute path. */
36+
public Path entryScriptPath() { return dir.resolve(entryScript); }
37+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package org.streamflow.plugins;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import org.streamflow.bridge.RPaths;
6+
7+
import java.io.IOException;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
import java.util.ArrayList;
11+
import java.util.List;
12+
import java.util.stream.Stream;
13+
14+
/**
15+
* Discovers R plugins by scanning for {@code <root>/engine/plugins/<id>/plugin.json}.
16+
*
17+
* Plugins live in this repo and are bundled only into an R-enabled build (see release.yml's
18+
* {@code bundle_r}); the default installer is Python-only, so {@link #rAvailable()} is what the UI
19+
* uses to decide whether to offer them at all. Discovery itself never needs R, which keeps it
20+
* unit-testable on a machine without an R install.
21+
*/
22+
public final class PluginRegistry {
23+
24+
private static final ObjectMapper JSON = new ObjectMapper();
25+
26+
private PluginRegistry() {}
27+
28+
/** Candidate roots: the packaged app dir first, then the dev repo checkout. */
29+
static List<Path> pluginRoots() {
30+
List<Path> roots = new ArrayList<>();
31+
try { roots.add(RPaths.appDir().resolve("engine").resolve("plugins")); } catch (Exception ignored) {}
32+
try { roots.add(RPaths.repoRoot().resolve("engine").resolve("plugins")); } catch (Exception ignored) {}
33+
return roots;
34+
}
35+
36+
/** True when an Rscript executable can be resolved (bundled R-Portable, R_HOME, a system R, or PATH). */
37+
public static boolean rAvailable() {
38+
try {
39+
Path rs = RPaths.rscript();
40+
// A bare "Rscript"/"Rscript.exe" means "hope it's on PATH" — only trust an absolute hit.
41+
return rs != null && rs.isAbsolute() && Files.exists(rs);
42+
} catch (Exception e) {
43+
return false;
44+
}
45+
}
46+
47+
/** All plugins found under the first existing root. Never throws; a bad manifest is skipped. */
48+
public static List<PluginManifest> discover() {
49+
for (Path root : pluginRoots()) {
50+
if (root != null && Files.isDirectory(root)) return discoverIn(root);
51+
}
52+
return List.of();
53+
}
54+
55+
/** Parse the {@code plugin.json} of every sub-directory. Malformed manifests are skipped, not fatal. */
56+
public static List<PluginManifest> discoverIn(Path root) {
57+
List<PluginManifest> out = new ArrayList<>();
58+
try (Stream<Path> dirs = Files.list(root)) {
59+
for (Path dir : dirs.filter(Files::isDirectory).toList()) {
60+
Path manifest = dir.resolve("plugin.json");
61+
if (!Files.isRegularFile(manifest)) continue;
62+
try {
63+
out.add(parse(dir, manifest));
64+
} catch (Exception ignored) {
65+
// A single broken plugin must not hide the healthy ones.
66+
}
67+
}
68+
} catch (IOException ignored) { }
69+
out.sort(java.util.Comparator.comparing(PluginManifest::name, String.CASE_INSENSITIVE_ORDER));
70+
return out;
71+
}
72+
73+
static PluginManifest parse(Path dir, Path manifestFile) throws IOException {
74+
JsonNode n = JSON.readTree(Files.readString(manifestFile));
75+
JsonNode entry = n.path("entry");
76+
String script = entry.path("script").asText(null);
77+
String function = entry.path("function").asText(null);
78+
if (script == null || function == null) {
79+
throw new IOException("plugin.json must declare entry.script and entry.function");
80+
}
81+
String id = n.path("id").asText(dir.getFileName().toString());
82+
83+
List<String> pkgs = new ArrayList<>();
84+
n.path("requires").path("packages").forEach(p -> pkgs.add(p.asText()));
85+
86+
List<PluginManifest.Param> params = new ArrayList<>();
87+
for (JsonNode p : n.path("params")) {
88+
params.add(new PluginManifest.Param(
89+
p.path("id").asText(),
90+
p.path("label").asText(p.path("id").asText()),
91+
p.path("type").asText("string"),
92+
p.path("required").asBoolean(false),
93+
p.path("help").asText(null),
94+
p.has("min") ? p.path("min").asDouble() : null,
95+
p.has("max") ? p.path("max").asDouble() : null,
96+
p.has("default") ? toPlain(p.path("default")) : null));
97+
}
98+
99+
List<String> outputs = new ArrayList<>();
100+
n.path("outputs").forEach(o -> outputs.add(o.asText()));
101+
102+
return new PluginManifest(dir, id,
103+
n.path("name").asText(id),
104+
n.path("version").asText("0.0.0"),
105+
n.path("description").asText(""),
106+
n.path("status").asText("experimental"),
107+
script, function, pkgs, params, outputs);
108+
}
109+
110+
private static Object toPlain(JsonNode v) {
111+
if (v.isBoolean()) return v.asBoolean();
112+
if (v.isInt() || v.isLong()) return v.asLong();
113+
if (v.isNumber()) return v.asDouble();
114+
return v.asText();
115+
}
116+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package org.streamflow.plugins;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.node.ObjectNode;
6+
import javafx.concurrent.Task;
7+
import org.streamflow.bridge.RPaths;
8+
9+
import java.io.IOException;
10+
import java.nio.file.Files;
11+
import java.nio.file.Path;
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
/**
16+
* Executes an R plugin through {@code engine/run_plugin.R}.
17+
*
18+
* The shim always writes a JSON response — including {@code {"ok": false, "error": ...}} — so this
19+
* never has to parse an R stack trace. Anything R prints to stdout/stderr is captured as the plugin
20+
* log and surfaced to the user when a run fails.
21+
*/
22+
public final class PluginRunner {
23+
24+
private static final ObjectMapper JSON = new ObjectMapper();
25+
26+
private PluginRunner() {}
27+
28+
/** Thrown when the plugin ran but reported {@code ok:false}, or the runner itself failed. */
29+
public static class PluginException extends RuntimeException {
30+
public PluginException(String message) { super(message); }
31+
}
32+
33+
/** Locate {@code engine/run_plugin.R} next to the packaged app or in the dev checkout. */
34+
static Path runnerScript() {
35+
for (Path base : List.of(RPaths.appDir(), RPaths.repoRoot())) {
36+
if (base == null) continue;
37+
Path p = base.resolve("engine").resolve("run_plugin.R");
38+
if (Files.isRegularFile(p)) return p;
39+
}
40+
throw new PluginException("engine/run_plugin.R not found (is the R runway installed?)");
41+
}
42+
43+
/**
44+
* Background task that runs {@code plugin} with {@code request} and yields the parsed response.
45+
* Never runs on the FX thread — plugins can take minutes.
46+
*/
47+
public static Task<JsonNode> run(PluginManifest plugin, Map<String, Object> request) {
48+
return new Task<>() {
49+
@Override protected JsonNode call() throws Exception {
50+
Path rscript = RPaths.rscript();
51+
if (rscript == null) throw new PluginException("No R installation found.");
52+
53+
Path work = Files.createTempDirectory("sf-plugin-");
54+
Path reqFile = work.resolve("request.json");
55+
Path respFile = work.resolve("response.json");
56+
try {
57+
ObjectNode req = JSON.createObjectNode();
58+
request.forEach((k, v) -> req.set(k, JSON.valueToTree(v)));
59+
Files.writeString(reqFile, JSON.writeValueAsString(req));
60+
61+
ProcessBuilder pb = new ProcessBuilder(
62+
rscript.toString(), runnerScript().toString(),
63+
plugin.dir().toString(), reqFile.toString(), respFile.toString());
64+
pb.redirectErrorStream(true);
65+
Process proc = pb.start();
66+
67+
StringBuilder log = new StringBuilder();
68+
try (var r = proc.inputReader()) {
69+
String line;
70+
while ((line = r.readLine()) != null) {
71+
log.append(line).append('\n');
72+
updateMessage(line); // surface progress in the UI
73+
if (isCancelled()) { proc.destroy(); throw new InterruptedException("cancelled"); }
74+
}
75+
}
76+
int exit = proc.waitFor();
77+
78+
if (!Files.isRegularFile(respFile)) {
79+
throw new PluginException("Plugin produced no response (exit " + exit + ").\n"
80+
+ tail(log.toString()));
81+
}
82+
JsonNode resp = JSON.readTree(Files.readString(respFile));
83+
if (!resp.path("ok").asBoolean(false)) {
84+
throw new PluginException(resp.path("error").asText("Plugin failed.")
85+
+ "\n" + tail(log.toString()));
86+
}
87+
return resp;
88+
} finally {
89+
deleteQuietly(respFile);
90+
deleteQuietly(reqFile);
91+
deleteQuietly(work);
92+
}
93+
}
94+
};
95+
}
96+
97+
/** 0-based row indices from a plugin response field (e.g. {@code kept_indices}). */
98+
public static int[] indices(JsonNode resp, String field) {
99+
JsonNode arr = resp.path(field);
100+
if (!arr.isArray()) return new int[0];
101+
int[] out = new int[arr.size()];
102+
for (int i = 0; i < out.length; i++) out[i] = arr.get(i).asInt();
103+
return out;
104+
}
105+
106+
private static String tail(String s) {
107+
String[] lines = s.strip().split("\n");
108+
int from = Math.max(0, lines.length - 12);
109+
return String.join("\n", java.util.Arrays.copyOfRange(lines, from, lines.length));
110+
}
111+
112+
private static void deleteQuietly(Path p) {
113+
try { Files.deleteIfExists(p); } catch (IOException ignored) { }
114+
}
115+
}

0 commit comments

Comments
 (0)