diff --git a/build.gradle b/build.gradle
index 9a33213..9fc6e77 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,16 +1,9 @@
plugins {
id("com.github.johnrengelman.shadow") version "8.1.1" apply(false)
- id("io.micronaut.application") version "${micronautGradlePluginVersion}" apply(false)
- id("io.micronaut.aot") version "${micronautGradlePluginVersion}" apply(false)
- id("io.micronaut.docker") version "${micronautGradlePluginVersion}" apply(false)
- id("com.google.protobuf") version "0.9.4" apply(false)
id("maven-publish")
id("com.diffplug.spotless") version "7.0.3"
- id("com.autonomousapps.dependency-analysis") version "2.17.0"
}
-
-
repositories {
mavenCentral()
}
@@ -19,7 +12,7 @@ allprojects {
apply plugin: 'java'
apply plugin: 'maven-publish'
apply plugin: 'com.diffplug.spotless'
- apply plugin: 'com.autonomousapps.dependency-analysis'
+
repositories {
mavenCentral()
maven {
@@ -28,9 +21,12 @@ allprojects {
}
}
+ java {
+ sourceCompatibility = JavaVersion.toVersion("21")
+ targetCompatibility = JavaVersion.toVersion("21")
+ }
+
dependencies {
- implementation 'ch.qos.logback:logback-classic:1.5.17'
- implementation 'ch.qos.logback:logback-core:1.5.17'
implementation 'org.slf4j:slf4j-api:2.0.17'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
@@ -38,6 +34,11 @@ allprojects {
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
}
+
+ test {
+ useJUnitPlatform()
+ }
+
publishing {
publications {
maven(MavenPublication) {
@@ -56,17 +57,16 @@ allprojects {
}
}
}
+
jar {
manifest {
attributes 'Implementation-Version': project.version
}
}
- spotless {
+ spotless {
format 'misc', {
- // define the files to apply `misc` to
target '*.gradle', '.gitattributes', '.gitignore'
-
trimTrailingWhitespace()
leadingSpacesToTabs()
endWithNewline()
@@ -74,22 +74,7 @@ allprojects {
java {
googleJavaFormat()
formatAnnotations()
- licenseHeader('''/*
- * (C) 2017-$YEAR TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.
- *
- */''')
+ targetExclude("build/**")
}
}
}
diff --git a/config/build.gradle b/config/build.gradle
new file mode 100644
index 0000000..72e62ce
--- /dev/null
+++ b/config/build.gradle
@@ -0,0 +1,11 @@
+plugins {
+ id("java-library")
+ id("maven-publish")
+}
+
+dependencies {
+ api(project(':core'))
+
+ // File watching — the only external dependency beyond core.
+ implementation("io.methvin:directory-watcher:0.18.0")
+}
diff --git a/config/src/main/java/com/tcn/exile/config/CertificateRotator.java b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java
new file mode 100644
index 0000000..d95185a
--- /dev/null
+++ b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java
@@ -0,0 +1,113 @@
+package com.tcn.exile.config;
+
+import com.tcn.exile.ExileConfig;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HexFormat;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks certificate expiration and rotates via the gate config service.
+ *
+ *
Intended to be called periodically (e.g., every hour). If the certificate expires within the
+ * configured threshold (default 10 days), it calls the gate service to rotate and writes the new
+ * certificate to the config file.
+ */
+public final class CertificateRotator {
+
+ private static final Logger log = LoggerFactory.getLogger(CertificateRotator.class);
+ private static final int DEFAULT_RENEWAL_DAYS = 10;
+
+ private final ExileClientManager manager;
+ private final int renewalDays;
+
+ public CertificateRotator(ExileClientManager manager) {
+ this(manager, DEFAULT_RENEWAL_DAYS);
+ }
+
+ public CertificateRotator(ExileClientManager manager, int renewalDays) {
+ this.manager = manager;
+ this.renewalDays = renewalDays;
+ }
+
+ /**
+ * Check and rotate if needed. Returns true if rotation was performed.
+ *
+ *
Call this periodically (e.g., hourly).
+ */
+ public boolean checkAndRotate() {
+ var client = manager.client();
+ if (client == null) return false;
+
+ var config = client.config();
+ Instant expiration = getCertExpiration(config);
+ if (expiration == null) {
+ log.warn("Could not determine certificate expiration date");
+ return false;
+ }
+
+ var now = Instant.now();
+ if (expiration.isBefore(now)) {
+ log.error("Certificate has expired ({}). Manual renewal required.", expiration);
+ manager.stop();
+ return false;
+ }
+
+ if (expiration.isBefore(now.plus(renewalDays, ChronoUnit.DAYS))) {
+ log.info("Certificate expires at {}, attempting rotation", expiration);
+ try {
+ var hash = getCertFingerprint(config);
+ var newCert = client.config_().rotateCertificate(hash);
+ if (newCert != null && !newCert.isEmpty()) {
+ manager.configWatcher().writeConfig(newCert);
+ log.info("Certificate rotated successfully");
+ return true;
+ } else {
+ log.warn("Certificate rotation returned empty response");
+ }
+ } catch (Exception e) {
+ log.error("Certificate rotation failed: {}", e.getMessage());
+ }
+ } else {
+ log.debug(
+ "Certificate valid until {}, no rotation needed ({} day threshold)",
+ expiration,
+ renewalDays);
+ }
+ return false;
+ }
+
+ static Instant getCertExpiration(ExileConfig config) {
+ try {
+ var cf = CertificateFactory.getInstance("X.509");
+ var cert =
+ (X509Certificate)
+ cf.generateCertificate(
+ new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)));
+ return cert.getNotAfter().toInstant();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ static String getCertFingerprint(ExileConfig config) {
+ try {
+ var cf = CertificateFactory.getInstance("X.509");
+ var cert =
+ (X509Certificate)
+ cf.generateCertificate(
+ new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)));
+ var digest = MessageDigest.getInstance("SHA-256");
+ var hash = digest.digest(cert.getEncoded());
+ return HexFormat.of().withDelimiter(":").formatHex(hash);
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java
new file mode 100644
index 0000000..08ae746
--- /dev/null
+++ b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java
@@ -0,0 +1,168 @@
+package com.tcn.exile.config;
+
+import com.tcn.exile.ExileConfig;
+import io.methvin.watcher.DirectoryWatcher;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Watches the config directory for changes to the exile config file and invokes a callback.
+ *
+ *
Replaces the 400+ line ConfigChangeWatcher that was copy-pasted across all integrations.
+ *
+ *
The watcher monitors the standard config file ({@code com.tcn.exiles.sati.config.cfg}) in the
+ * standard directories ({@code /workdir/config} and {@code workdir/config}). On create/modify, it
+ * parses the file and calls the configured {@link Listener}. On delete, it calls {@link
+ * Listener#onConfigRemoved()}.
+ */
+public final class ConfigFileWatcher implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(ConfigFileWatcher.class);
+ static final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg";
+ private static final List DEFAULT_WATCH_DIRS =
+ List.of(Path.of("/workdir/config"), Path.of("workdir/config"));
+
+ private final List watchDirs;
+ private final Listener listener;
+ private final AtomicBoolean started = new AtomicBoolean(false);
+ private volatile DirectoryWatcher watcher;
+ private volatile Path configDir;
+
+ /** Callback interface for config change events. */
+ public interface Listener {
+ /** Called when a new or updated config is detected. */
+ void onConfigChanged(ExileConfig config);
+
+ /** Called when the config file is deleted. */
+ void onConfigRemoved();
+ }
+
+ private ConfigFileWatcher(List watchDirs, Listener listener) {
+ this.watchDirs = watchDirs;
+ this.listener = listener;
+ }
+
+ public static ConfigFileWatcher create(Listener listener) {
+ return new ConfigFileWatcher(DEFAULT_WATCH_DIRS, listener);
+ }
+
+ public static ConfigFileWatcher create(List watchDirs, Listener listener) {
+ return new ConfigFileWatcher(watchDirs, listener);
+ }
+
+ /**
+ * Start watching. Reads any existing config file immediately, then watches for changes
+ * asynchronously. Call this once.
+ */
+ public void start() throws IOException {
+ if (!started.compareAndSet(false, true)) {
+ throw new IllegalStateException("Already started");
+ }
+
+ // Find or create the config directory.
+ configDir = watchDirs.stream().filter(p -> p.toFile().exists()).findFirst().orElse(null);
+ if (configDir == null) {
+ var fallback = watchDirs.get(0);
+ if (fallback.toFile().mkdirs()) {
+ configDir = fallback;
+ log.info("Created config directory: {}", configDir);
+ } else {
+ log.warn("Could not find or create config directory from: {}", watchDirs);
+ }
+ } else {
+ log.info("Using config directory: {}", configDir);
+ }
+
+ // Read existing config if present.
+ loadExistingConfig();
+
+ // Start watching.
+ var existingDirs = watchDirs.stream().filter(p -> p.toFile().exists()).toList();
+ if (existingDirs.isEmpty()) {
+ log.warn("No config directories exist to watch");
+ return;
+ }
+
+ watcher =
+ DirectoryWatcher.builder()
+ .paths(existingDirs)
+ .fileHashing(false)
+ .listener(
+ event -> {
+ if (!event.path().getFileName().toString().equals(CONFIG_FILE_NAME)) return;
+ switch (event.eventType()) {
+ case CREATE, MODIFY -> handleConfigFileChange(event.path());
+ case DELETE -> {
+ log.info("Config file deleted");
+ listener.onConfigRemoved();
+ }
+ default -> log.debug("Ignoring event: {}", event.eventType());
+ }
+ })
+ .build();
+ watcher.watchAsync();
+ log.info("Config file watcher started");
+ }
+
+ /** Returns the directory where the config file was found or created. */
+ public Path configDir() {
+ return configDir;
+ }
+
+ /**
+ * Write a config string (e.g., a rotated certificate) to the config file. This triggers the
+ * watcher to reload.
+ */
+ public void writeConfig(String content) throws IOException {
+ if (configDir == null) {
+ throw new IllegalStateException("No config directory available");
+ }
+ var file = configDir.resolve(CONFIG_FILE_NAME);
+ Files.writeString(file, content);
+ log.info("Wrote config file: {}", file);
+ }
+
+ private void loadExistingConfig() {
+ if (configDir == null) return;
+ var file = configDir.resolve(CONFIG_FILE_NAME);
+ if (file.toFile().exists()) {
+ ConfigParser.parse(file)
+ .ifPresent(
+ config -> {
+ log.info("Loaded existing config for org={}", config.org());
+ listener.onConfigChanged(config);
+ });
+ }
+ }
+
+ private void handleConfigFileChange(Path path) {
+ if (!path.toFile().canRead()) {
+ log.warn("Config file not readable: {}", path);
+ return;
+ }
+ ConfigParser.parse(path)
+ .ifPresentOrElse(
+ config -> {
+ log.info("Config changed for org={}", config.org());
+ listener.onConfigChanged(config);
+ },
+ () -> log.warn("Failed to parse config file: {}", path));
+ }
+
+ @Override
+ public void close() {
+ if (watcher != null) {
+ try {
+ watcher.close();
+ log.info("Config file watcher closed");
+ } catch (IOException e) {
+ log.warn("Error closing config file watcher", e);
+ }
+ }
+ }
+}
diff --git a/config/src/main/java/com/tcn/exile/config/ConfigParser.java b/config/src/main/java/com/tcn/exile/config/ConfigParser.java
new file mode 100644
index 0000000..50a108f
--- /dev/null
+++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java
@@ -0,0 +1,177 @@
+package com.tcn.exile.config;
+
+import com.tcn.exile.ExileConfig;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Parses the Base64-encoded JSON config file used by all exile integrations.
+ *
+ * The file format is a Base64-encoded JSON object with fields: {@code ca_certificate}, {@code
+ * certificate}, {@code private_key}, and {@code api_endpoint} (as {@code hostname:port}).
+ */
+public final class ConfigParser {
+
+ private static final Logger log = LoggerFactory.getLogger(ConfigParser.class);
+
+ private ConfigParser() {}
+
+ /** Parse a config file at the given path. Returns empty if the file can't be parsed. */
+ public static Optional parse(Path path) {
+ try {
+ var bytes = Files.readAllBytes(path);
+ return parse(bytes);
+ } catch (Exception e) {
+ log.warn("Failed to read config file {}: {}", path, e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /** Parse config from raw bytes (Base64-encoded JSON). */
+ public static Optional parse(byte[] raw) {
+ try {
+ // Trim trailing whitespace/newline that some editors add.
+ var trimmed = new String(raw, StandardCharsets.UTF_8).trim().getBytes(StandardCharsets.UTF_8);
+
+ // Try Base64 decode; if it fails, try as-is (already decoded).
+ byte[] json;
+ try {
+ json = Base64.getDecoder().decode(trimmed);
+ } catch (IllegalArgumentException e) {
+ json = trimmed;
+ }
+
+ var map = parseJson(json);
+ if (map == null) return Optional.empty();
+
+ var rootCert = (String) map.get("ca_certificate");
+ var publicCert = (String) map.get("certificate");
+ var privateKey = (String) map.get("private_key");
+ var endpoint = (String) map.get("api_endpoint");
+
+ if (rootCert == null || publicCert == null || privateKey == null) {
+ log.warn("Config missing required certificate fields");
+ return Optional.empty();
+ }
+
+ var builder =
+ ExileConfig.builder().rootCert(rootCert).publicCert(publicCert).privateKey(privateKey);
+
+ var certName = (String) map.get("certificate_name");
+ if (certName != null) builder.certificateName(certName);
+
+ if (endpoint != null && !endpoint.isEmpty()) {
+ // Handle both "host:port" and URL formats like "https://host" or "https://host:port".
+ String host = endpoint;
+ int port = 443;
+ if (host.contains("://")) {
+ // Strip scheme (https://, http://).
+ host = host.substring(host.indexOf("://") + 3);
+ }
+ // Strip trailing slashes.
+ if (host.endsWith("/")) {
+ host = host.substring(0, host.length() - 1);
+ }
+ // Split host:port.
+ int colonIdx = host.lastIndexOf(':');
+ if (colonIdx > 0) {
+ try {
+ port = Integer.parseInt(host.substring(colonIdx + 1));
+ host = host.substring(0, colonIdx);
+ } catch (NumberFormatException e) {
+ // No valid port — use host as-is with default port.
+ }
+ }
+ builder.apiHostname(host);
+ builder.apiPort(port);
+ }
+
+ return Optional.of(builder.build());
+ } catch (Exception e) {
+ log.warn("Failed to parse config: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Minimal JSON object parser for the config file. Returns a flat Map of string keys to string
+ * values. Avoids pulling in Jackson/Gson as a dependency for this single use case.
+ */
+ @SuppressWarnings("unchecked")
+ private static Map parseJson(byte[] json) {
+ // Use the built-in scripting approach: parse as key-value pairs.
+ // The config JSON is simple: {"key": "value", ...} with string values only.
+ try {
+ var str = new String(json, StandardCharsets.UTF_8).trim();
+ if (!str.startsWith("{") || !str.endsWith("}")) return null;
+ str = str.substring(1, str.length() - 1);
+
+ var result = new java.util.LinkedHashMap();
+ int i = 0;
+ while (i < str.length()) {
+ // Skip whitespace and commas.
+ while (i < str.length()
+ && (str.charAt(i) == ' '
+ || str.charAt(i) == ','
+ || str.charAt(i) == '\n'
+ || str.charAt(i) == '\r'
+ || str.charAt(i) == '\t')) i++;
+ if (i >= str.length()) break;
+
+ // Parse key.
+ if (str.charAt(i) != '"') break;
+ int keyStart = ++i;
+ while (i < str.length() && str.charAt(i) != '"') {
+ if (str.charAt(i) == '\\') i++; // skip escaped char
+ i++;
+ }
+ var key = str.substring(keyStart, i);
+ i++; // closing quote
+
+ // Skip colon.
+ while (i < str.length() && (str.charAt(i) == ' ' || str.charAt(i) == ':')) i++;
+
+ // Parse value.
+ if (i >= str.length()) break;
+ if (str.charAt(i) == '"') {
+ int valStart = ++i;
+ var sb = new StringBuilder();
+ while (i < str.length() && str.charAt(i) != '"') {
+ if (str.charAt(i) == '\\' && i + 1 < str.length()) {
+ i++;
+ sb.append(
+ switch (str.charAt(i)) {
+ case 'n' -> '\n';
+ case 'r' -> '\r';
+ case 't' -> '\t';
+ case '\\' -> '\\';
+ case '"' -> '"';
+ default -> str.charAt(i);
+ });
+ } else {
+ sb.append(str.charAt(i));
+ }
+ i++;
+ }
+ result.put(key, sb.toString());
+ i++; // closing quote
+ } else {
+ // Non-string value (number, boolean, null) — read until comma/brace.
+ int valStart = i;
+ while (i < str.length() && str.charAt(i) != ',' && str.charAt(i) != '}') i++;
+ result.put(key, str.substring(valStart, i).trim());
+ }
+ }
+ return result;
+ } catch (Exception e) {
+ log.warn("JSON parse error: {}", e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java
new file mode 100644
index 0000000..177ade2
--- /dev/null
+++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java
@@ -0,0 +1,230 @@
+package com.tcn.exile.config;
+
+import com.tcn.exile.ExileClient;
+import com.tcn.exile.ExileConfig;
+import com.tcn.exile.StreamStatus;
+import com.tcn.exile.handler.Plugin;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages the lifecycle of a single-tenant {@link ExileClient} driven by a config file.
+ *
+ * Usage:
+ *
+ *
{@code
+ * var manager = ExileClientManager.builder()
+ * .clientName("sati-finvi")
+ * .clientVersion("3.0.0")
+ * .maxConcurrency(5)
+ * .plugin(new FinviPlugin(dataSource))
+ * .build();
+ *
+ * manager.start();
+ * }
+ */
+public final class ExileClientManager implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(ExileClientManager.class);
+
+ private final String clientName;
+ private final String clientVersion;
+ private final int maxConcurrency;
+ private final Plugin plugin;
+ private final List watchDirs;
+ private final int certRotationHours;
+
+ private volatile ExileClient activeClient;
+ private volatile ExileConfig activeConfig;
+ private volatile String activeOrg;
+ private ConfigFileWatcher watcher;
+ private ScheduledExecutorService scheduler;
+
+ private ExileClientManager(Builder builder) {
+ this.clientName = builder.clientName;
+ this.clientVersion = builder.clientVersion;
+ this.maxConcurrency = builder.maxConcurrency;
+ this.plugin = builder.plugin;
+ this.watchDirs = builder.watchDirs;
+ this.certRotationHours = builder.certRotationHours;
+ }
+
+ /** Start watching the config file and managing the client lifecycle. */
+ public void start() throws IOException {
+ watcher =
+ watchDirs != null
+ ? ConfigFileWatcher.create(watchDirs, new WatcherListener())
+ : ConfigFileWatcher.create(new WatcherListener());
+ watcher.start();
+
+ scheduler =
+ Executors.newSingleThreadScheduledExecutor(
+ r -> {
+ var t = new Thread(r, "exile-cert-rotator");
+ t.setDaemon(true);
+ return t;
+ });
+ var rotator = new CertificateRotator(this);
+ scheduler.scheduleAtFixedRate(
+ () -> {
+ try {
+ rotator.checkAndRotate();
+ } catch (Exception e) {
+ log.warn("Certificate rotation check failed: {}", e.getMessage());
+ }
+ },
+ certRotationHours,
+ certRotationHours,
+ TimeUnit.HOURS);
+
+ log.info(
+ "ExileClientManager started (clientName={}, plugin={})", clientName, plugin.pluginName());
+ }
+
+ /** Returns the currently active client, or null if no config is loaded. */
+ public ExileClient client() {
+ return activeClient;
+ }
+
+ /** Returns a snapshot of the work stream status, or null if no client is active. */
+ public StreamStatus streamStatus() {
+ var c = activeClient;
+ return c != null ? c.streamStatus() : null;
+ }
+
+ ConfigFileWatcher configWatcher() {
+ return watcher;
+ }
+
+ public void stop() {
+ log.info("Stopping ExileClientManager");
+ destroyClient();
+ if (watcher != null) watcher.close();
+ if (scheduler != null) scheduler.shutdownNow();
+ }
+
+ @Override
+ public void close() {
+ stop();
+ }
+
+ private void createClient(ExileConfig config) {
+ log.info(
+ "Creating ExileClient (endpoint={}:{}, org={}, plugin={})",
+ config.apiHostname(),
+ config.apiPort(),
+ config.org(),
+ plugin.pluginName());
+ var newOrg = config.org();
+ if (activeOrg != null && !activeOrg.equals(newOrg)) {
+ log.info("Org changed from {} to {}, destroying old client", activeOrg, newOrg);
+ destroyClient();
+ }
+
+ destroyClient();
+
+ try {
+ activeClient =
+ ExileClient.builder()
+ .config(config)
+ .clientName(clientName)
+ .clientVersion(clientVersion)
+ .maxConcurrency(maxConcurrency)
+ .plugin(plugin)
+ .build();
+ activeClient.start();
+ activeConfig = config;
+ activeOrg = newOrg;
+ log.info("ExileClient started for org={}", newOrg);
+ } catch (Exception e) {
+ log.error("Failed to create ExileClient for org={}: {}", newOrg, e.getMessage(), e);
+ destroyClient();
+ }
+ }
+
+ private void destroyClient() {
+ var c = activeClient;
+ if (c != null) {
+ try {
+ c.close();
+ log.info("ExileClient closed for org={}", activeOrg);
+ } catch (Exception e) {
+ log.warn("Error closing ExileClient: {}", e.getMessage());
+ }
+ activeClient = null;
+ activeConfig = null;
+ }
+ }
+
+ private class WatcherListener implements ConfigFileWatcher.Listener {
+ @Override
+ public void onConfigChanged(ExileConfig config) {
+ createClient(config);
+ }
+
+ @Override
+ public void onConfigRemoved() {
+ log.info("Config removed, destroying client");
+ destroyClient();
+ activeOrg = null;
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String clientName = "sati";
+ private String clientVersion = "unknown";
+ private int maxConcurrency = 100;
+ private Plugin plugin;
+ private List watchDirs;
+ private int certRotationHours = 1;
+
+ private Builder() {}
+
+ public Builder clientName(String clientName) {
+ this.clientName = Objects.requireNonNull(clientName);
+ return this;
+ }
+
+ public Builder clientVersion(String clientVersion) {
+ this.clientVersion = Objects.requireNonNull(clientVersion);
+ return this;
+ }
+
+ public Builder maxConcurrency(int maxConcurrency) {
+ this.maxConcurrency = maxConcurrency;
+ return this;
+ }
+
+ /** The plugin that handles jobs, events, and config validation. */
+ public Builder plugin(Plugin plugin) {
+ this.plugin = Objects.requireNonNull(plugin);
+ return this;
+ }
+
+ public Builder watchDirs(List watchDirs) {
+ this.watchDirs = watchDirs;
+ return this;
+ }
+
+ public Builder certRotationHours(int hours) {
+ this.certRotationHours = hours;
+ return this;
+ }
+
+ public ExileClientManager build() {
+ Objects.requireNonNull(plugin, "plugin is required");
+ return new ExileClientManager(this);
+ }
+ }
+}
diff --git a/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java
new file mode 100644
index 0000000..08962ab
--- /dev/null
+++ b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java
@@ -0,0 +1,213 @@
+package com.tcn.exile.config;
+
+import com.tcn.exile.ExileClient;
+import com.tcn.exile.ExileConfig;
+import com.tcn.exile.StreamStatus;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages multiple {@link ExileClient} instances for multi-tenant deployments.
+ *
+ * Polls a tenant provider for the current set of tenants and their configs. Creates clients for
+ * new tenants, destroys clients for removed tenants, and updates clients when config changes.
+ *
+ *
Usage:
+ *
+ *
{@code
+ * var manager = MultiTenantManager.builder()
+ * .tenantProvider(() -> velosidyApi.listTenants())
+ * .clientFactory(tenantConfig -> ExileClient.builder()
+ * .config(tenantConfig)
+ * .jobHandler(new VelosidyJobHandler(tenantConfig))
+ * .eventHandler(new VelosidyEventHandler())
+ * .build())
+ * .pollInterval(Duration.ofSeconds(30))
+ * .build();
+ *
+ * manager.start();
+ *
+ * // Access a specific tenant's client.
+ * manager.client("tenant-org-123").agents().listAgents(...);
+ *
+ * // List all active tenants.
+ * manager.tenantIds();
+ *
+ * manager.stop();
+ * }
+ */
+public final class MultiTenantManager implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(MultiTenantManager.class);
+
+ private final Supplier> tenantProvider;
+ private final Function clientFactory;
+ private final long pollIntervalSeconds;
+
+ private final ConcurrentHashMap clients = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap configs = new ConcurrentHashMap<>();
+ private volatile ScheduledExecutorService scheduler;
+
+ private MultiTenantManager(Builder builder) {
+ this.tenantProvider = builder.tenantProvider;
+ this.clientFactory = builder.clientFactory;
+ this.pollIntervalSeconds = builder.pollIntervalSeconds;
+ }
+
+ /** Start polling for tenants. */
+ public void start() {
+ scheduler =
+ Executors.newSingleThreadScheduledExecutor(
+ r -> {
+ var t = new Thread(r, "exile-tenant-poller");
+ t.setDaemon(true);
+ return t;
+ });
+ scheduler.scheduleAtFixedRate(this::reconcile, 0, pollIntervalSeconds, TimeUnit.SECONDS);
+ log.info("MultiTenantManager started (poll={}s)", pollIntervalSeconds);
+ }
+
+ /** Get the client for a specific tenant. Returns null if tenant not active. */
+ public ExileClient client(String tenantId) {
+ return clients.get(tenantId);
+ }
+
+ /** Returns all active tenant IDs. */
+ public Set tenantIds() {
+ return Collections.unmodifiableSet(clients.keySet());
+ }
+
+ /** Returns stream status for all tenants. */
+ public Map allStatuses() {
+ var result = new ConcurrentHashMap();
+ clients.forEach((id, client) -> result.put(id, client.streamStatus()));
+ return result;
+ }
+
+ /** Stop all tenants and the polling loop. */
+ public void stop() {
+ log.info("Stopping MultiTenantManager ({} tenants)", clients.size());
+ if (scheduler != null) scheduler.shutdownNow();
+ clients.keySet().forEach(this::destroyTenant);
+ }
+
+ @Override
+ public void close() {
+ stop();
+ }
+
+ private void reconcile() {
+ try {
+ var desired = tenantProvider.get();
+ if (desired == null) {
+ log.warn("Tenant provider returned null");
+ return;
+ }
+
+ // Remove tenants no longer in the desired set.
+ for (var existingId : clients.keySet()) {
+ if (!desired.containsKey(existingId)) {
+ log.info("Tenant {} removed, destroying client", existingId);
+ destroyTenant(existingId);
+ }
+ }
+
+ // Create or update tenants.
+ for (var entry : desired.entrySet()) {
+ var tenantId = entry.getKey();
+ var newConfig = entry.getValue();
+ var existingConfig = configs.get(tenantId);
+
+ if (existingConfig == null) {
+ // New tenant.
+ createTenant(tenantId, newConfig);
+ } else if (!existingConfig.org().equals(newConfig.org())) {
+ // Config changed (different org/certs).
+ log.info("Tenant {} config changed, recreating client", tenantId);
+ destroyTenant(tenantId);
+ createTenant(tenantId, newConfig);
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Tenant reconciliation failed: {}", e.getMessage());
+ }
+ }
+
+ private void createTenant(String tenantId, ExileConfig config) {
+ try {
+ var client = clientFactory.apply(config);
+ client.start();
+ clients.put(tenantId, client);
+ configs.put(tenantId, config);
+ log.info("Created client for tenant {} (org={})", tenantId, config.org());
+ } catch (Exception e) {
+ log.error("Failed to create client for tenant {}: {}", tenantId, e.getMessage());
+ }
+ }
+
+ private void destroyTenant(String tenantId) {
+ var client = clients.remove(tenantId);
+ configs.remove(tenantId);
+ if (client != null) {
+ try {
+ client.close();
+ log.info("Destroyed client for tenant {}", tenantId);
+ } catch (Exception e) {
+ log.warn("Error destroying client for tenant {}: {}", tenantId, e.getMessage());
+ }
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private Supplier> tenantProvider;
+ private Function clientFactory;
+ private long pollIntervalSeconds = 30;
+
+ private Builder() {}
+
+ /**
+ * Required. Provides the current set of tenants and their configs. Called on each poll cycle.
+ * The map key is the tenant ID, value is the ExileConfig for that tenant.
+ */
+ public Builder tenantProvider(Supplier> tenantProvider) {
+ this.tenantProvider = Objects.requireNonNull(tenantProvider);
+ return this;
+ }
+
+ /**
+ * Required. Factory that creates an ExileClient for a given config. The factory should call
+ * {@code ExileClient.builder()...build()} but NOT call {@code start()} — the manager handles
+ * that.
+ */
+ public Builder clientFactory(Function clientFactory) {
+ this.clientFactory = Objects.requireNonNull(clientFactory);
+ return this;
+ }
+
+ /** How often to poll the tenant provider (seconds). Default: 30. */
+ public Builder pollIntervalSeconds(long seconds) {
+ this.pollIntervalSeconds = seconds;
+ return this;
+ }
+
+ public MultiTenantManager build() {
+ Objects.requireNonNull(tenantProvider, "tenantProvider required");
+ Objects.requireNonNull(clientFactory, "clientFactory required");
+ return new MultiTenantManager(this);
+ }
+ }
+}
diff --git a/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java b/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java
new file mode 100644
index 0000000..0fa367f
--- /dev/null
+++ b/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java
@@ -0,0 +1,120 @@
+package com.tcn.exile.config;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.junit.jupiter.api.Test;
+
+class ConfigParserTest {
+
+ private static final String VALID_JSON =
+ """
+ {
+ "ca_certificate": "-----BEGIN CERTIFICATE-----\\nROOT\\n-----END CERTIFICATE-----",
+ "certificate": "-----BEGIN CERTIFICATE-----\\nPUBLIC\\n-----END CERTIFICATE-----",
+ "private_key": "-----BEGIN PRIVATE KEY-----\\nKEY\\n-----END PRIVATE KEY-----",
+ "api_endpoint": "gate.example.com:8443"
+ }
+ """;
+
+ @Test
+ void parsesBase64EncodedJson() {
+ var base64 = Base64.getEncoder().encode(VALID_JSON.getBytes(StandardCharsets.UTF_8));
+ var result = ConfigParser.parse(base64);
+ assertTrue(result.isPresent());
+ var config = result.get();
+ assertTrue(config.rootCert().contains("ROOT"));
+ assertTrue(config.publicCert().contains("PUBLIC"));
+ assertTrue(config.privateKey().contains("KEY"));
+ assertEquals("gate.example.com", config.apiHostname());
+ assertEquals(8443, config.apiPort());
+ }
+
+ @Test
+ void parsesRawJson() {
+ var raw = VALID_JSON.getBytes(StandardCharsets.UTF_8);
+ var result = ConfigParser.parse(raw);
+ assertTrue(result.isPresent());
+ assertEquals("gate.example.com", result.get().apiHostname());
+ }
+
+ @Test
+ void parsesJsonWithoutPort() {
+ var json =
+ """
+ {
+ "ca_certificate": "root",
+ "certificate": "pub",
+ "private_key": "key",
+ "api_endpoint": "gate.example.com"
+ }
+ """;
+ var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8));
+ assertTrue(result.isPresent());
+ assertEquals("gate.example.com", result.get().apiHostname());
+ assertEquals(443, result.get().apiPort());
+ }
+
+ @Test
+ void parsesJsonWithoutEndpoint() {
+ var json =
+ """
+ {
+ "ca_certificate": "root",
+ "certificate": "pub",
+ "private_key": "key"
+ }
+ """;
+ var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8));
+ // Should fail because apiHostname is required in ExileConfig
+ // but the JSON itself is valid
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ void rejectsMissingCertFields() {
+ var json =
+ """
+ {"api_endpoint": "gate.example.com:443"}
+ """;
+ var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8));
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ void rejectsGarbage() {
+ var result = ConfigParser.parse("not-json-or-base64".getBytes(StandardCharsets.UTF_8));
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ void rejectsEmptyInput() {
+ var result = ConfigParser.parse(new byte[0]);
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ void handlesBase64WithTrailingNewline() {
+ var base64 =
+ Base64.getEncoder().encodeToString(VALID_JSON.getBytes(StandardCharsets.UTF_8)) + "\n";
+ var result = ConfigParser.parse(base64.getBytes(StandardCharsets.UTF_8));
+ assertTrue(result.isPresent());
+ }
+
+ @Test
+ void handlesEscapedNewlinesInCerts() {
+ var json =
+ """
+ {
+ "ca_certificate": "line1\\nline2\\nline3",
+ "certificate": "pub",
+ "private_key": "key",
+ "api_endpoint": "host:443"
+ }
+ """;
+ var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8));
+ assertTrue(result.isPresent());
+ assertTrue(result.get().rootCert().contains("\n"));
+ }
+}
diff --git a/core/build.gradle b/core/build.gradle
index 66800ae..62eaaa1 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -1,91 +1,46 @@
plugins {
+ id("java-library")
id("com.github.johnrengelman.shadow")
- id("io.micronaut.library")
- id("io.micronaut.aot")
id("maven-publish")
id("jacoco")
}
-repositories {
- mavenLocal()
- mavenCentral()
-}
-
-dependencies {
- annotationProcessor("io.micronaut:micronaut-http-validation")
- annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
- annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
- runtimeOnly("io.micronaut:micronaut-http-client")
- runtimeOnly("io.micronaut:micronaut-http-server-netty")
- runtimeOnly("io.micronaut.validation:micronaut-validation")
-
- // Reactor for reactive streams
- implementation("io.projectreactor:reactor-core:3.5.12")
- api("jakarta.annotation:jakarta.annotation-api")
- api("jakarta.validation:jakarta.validation-api")
-
- runtimeOnly("ch.qos.logback:logback-classic")
-
- // uses exileapi v1.0.1 - NOTE: you must use api to propagate the proto files to the other projects
+dependencies {
+ // exileapi v3 pre-built stubs from buf.build BSR
api("build.buf.gen:tcn_exileapi_grpc_java:${exileapiGrpcVersion}")
+ api("build.buf.gen:tcn_exileapi_protocolbuffers_java:${exileapiProtobufVersion}")
- implementation("com.zaxxer:HikariCP:6.3.0")
-
- // gRPC client dependencies only (no server)
+ // gRPC — client only
+ api("io.grpc:grpc-api:${grpcVersion}")
api("io.grpc:grpc-protobuf:${grpcVersion}")
- api("io.grpc:grpc-services:${grpcVersion}") {
- exclude group: "io.grpc", module: "grpc-health-service"
- }
api("io.grpc:grpc-stub:${grpcVersion}")
- compileOnly("org.apache.tomcat:annotations-api:6.0.53")
- runtimeOnly("io.grpc:grpc-netty-shaded:${grpcVersion}")
- api("com.google.protobuf:protobuf-java-util:${protobufVersion}")
+ implementation("io.grpc:grpc-netty-shaded:${grpcVersion}")
- // Add SnakeYAML for YAML configuration support
- runtimeOnly("org.yaml:snakeyaml")
+ // protobuf runtime
+ api("com.google.protobuf:protobuf-java:${protobufVersion}")
- testRuntimeOnly("io.micronaut:micronaut-http-client")
+ // OpenTelemetry SDK — metrics, tracing, and custom exporter
+ implementation("io.opentelemetry:opentelemetry-api:1.46.0")
+ implementation("io.opentelemetry:opentelemetry-sdk:1.46.0")
+ implementation("io.opentelemetry:opentelemetry-sdk-metrics:1.46.0")
+ implementation("io.opentelemetry:opentelemetry-sdk-trace:1.46.0")
- api('io.micronaut.serde:micronaut-serde-api:2.12.1')
- implementation('com.google.protobuf:protobuf-java:4.28.3')
- api('io.micronaut:micronaut-context:4.7.14')
- api('io.micronaut:micronaut-core:4.7.14')
- api('com.fasterxml.jackson.core:jackson-annotations:2.17.2')
- api("build.buf.gen:tcn_exileapi_protocolbuffers_java:${exileapiProtobufVersion}")
- api("io.grpc:grpc-api:${grpcVersion}")
-}
+ // PKCS#1 → PKCS#8 key conversion for mTLS
+ implementation("org.bouncycastle:bcpkix-jdk18on:1.79")
-java {
- sourceCompatibility = JavaVersion.toVersion("21")
- targetCompatibility = JavaVersion.toVersion("21")
-}
+ // In-memory log buffer for listTenantLogs/setLogLevel
+ implementation(project(':logback-ext'))
-graalvmNative.toolchainDetection = false
+ // javax.annotation for @Generated in gRPC stubs
+ compileOnly("org.apache.tomcat:annotations-api:6.0.53")
-micronaut {
- runtime("netty")
- testRuntime("junit5")
- processing {
- incremental(true)
- annotations("com.tcn.exile.*")
- }
- aot {
- // Please review carefully the optimizations enabled below
- // Check https://micronaut-projects.github.io/micronaut-aot/latest/guide/ for more details
- optimizeServiceLoading = false
- convertYamlToJava = false
- precomputeOperations = true
- cacheEnvironment = true
- optimizeClassLoading = true
- deduceEnvironment = true
- optimizeNetty = true
- replaceLogbackXml = true
- }
+ // test
+ testImplementation("io.grpc:grpc-testing:${grpcVersion}")
+ testImplementation("io.grpc:grpc-inprocess:${grpcVersion}")
}
-// Configure JaCoCo for test coverage
jacoco {
toolVersion = "0.8.11"
}
@@ -95,23 +50,10 @@ jacocoTestReport {
xml.required = true
html.required = true
}
-
- afterEvaluate {
- classDirectories.setFrom(files(classDirectories.files.collect {
- fileTree(dir: it, exclude: [
- '**/*$Lambda$*/**',
- '**/*$$*/**',
- '**/*_Micronaut*/**',
- '**/micronaut/**'
- ])
- }))
- }
+ dependsOn test
}
test {
+ exclude '**/LiveBenchmark*'
finalizedBy jacocoTestReport
}
-
-jacocoTestReport {
- dependsOn test
-}
diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java
new file mode 100644
index 0000000..bfa0b60
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/ExileClient.java
@@ -0,0 +1,309 @@
+package com.tcn.exile;
+
+import com.tcn.exile.handler.Plugin;
+import com.tcn.exile.internal.ChannelFactory;
+import com.tcn.exile.internal.GrpcLogShipper;
+import com.tcn.exile.internal.MetricsManager;
+import com.tcn.exile.internal.WorkStreamClient;
+import com.tcn.exile.memlogger.MemoryAppender;
+import com.tcn.exile.memlogger.MemoryAppenderInstance;
+import com.tcn.exile.service.*;
+import io.grpc.ManagedChannel;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.api.trace.Span;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Main entry point for the Exile client library.
+ *
+ * On {@link #start()}, only the config poller begins. The WorkStream does not open until:
+ *
+ *
+ * The first successful config poll from the gate
+ * The {@link Plugin#onConfig} returns {@code true}
+ *
+ */
+public final class ExileClient implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(ExileClient.class);
+
+ private final ExileConfig config;
+ private final Plugin plugin;
+ private final WorkStreamClient workStream;
+ private final ManagedChannel serviceChannel;
+ private final ScheduledExecutorService configPoller;
+ private final Duration configPollInterval;
+
+ private final AgentService agentService;
+ private final CallService callService;
+ private final RecordingService recordingService;
+ private final ScrubListService scrubListService;
+ private final ConfigService configService;
+ private final JourneyService journeyService;
+ private final TelemetryService telemetryService;
+ private final String telemetryClientId;
+ private volatile MetricsManager metricsManager;
+
+ private volatile ConfigService.ClientConfiguration lastConfig;
+ private final AtomicBoolean pluginReady = new AtomicBoolean(false);
+ private final AtomicBoolean workStreamStarted = new AtomicBoolean(false);
+
+ private ExileClient(Builder builder) {
+ this.config = builder.config;
+ this.plugin = builder.plugin;
+ this.configPollInterval = builder.configPollInterval;
+
+ this.workStream =
+ new WorkStreamClient(
+ config,
+ plugin,
+ plugin,
+ builder.clientName,
+ builder.clientVersion,
+ builder.maxConcurrency,
+ builder.capabilities);
+
+ this.serviceChannel = ChannelFactory.create(config);
+ var services = ServiceFactory.create(serviceChannel);
+ this.agentService = services.agent();
+ this.callService = services.call();
+ this.recordingService = services.recording();
+ this.scrubListService = services.scrubList();
+ this.configService = services.config();
+ this.journeyService = services.journey();
+ this.telemetryService = services.telemetry();
+
+ // Telemetry client ID (stable across reconnects).
+ this.telemetryClientId =
+ builder.clientName + "-" + java.util.UUID.randomUUID().toString().substring(0, 8);
+
+ // Wire OTel trace context into log events so each LogRecord carries trace_id/span_id.
+ MemoryAppender.setTraceContextExtractor(
+ new MemoryAppender.TraceContextExtractor() {
+ @Override
+ public String traceId() {
+ var ctx = Span.current().getSpanContext();
+ return ctx.isValid() ? ctx.getTraceId() : null;
+ }
+
+ @Override
+ public String spanId() {
+ var ctx = Span.current().getSpanContext();
+ return ctx.isValid() ? ctx.getSpanId() : null;
+ }
+ });
+
+ // Structured log shipping starts immediately (doesn't need org_id).
+ var appender = MemoryAppenderInstance.getInstance();
+ if (appender != null) {
+ appender.enableLogShipper(new GrpcLogShipper(telemetryService, telemetryClientId));
+ }
+
+ // MetricsManager is created after first config poll (needs org_id + configName).
+
+ this.configPoller =
+ Executors.newSingleThreadScheduledExecutor(
+ r -> {
+ var t = new Thread(r, "exile-config-poller");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ /**
+ * Start the client. Only the config poller begins immediately. The WorkStream opens after the
+ * plugin accepts the first config via {@link Plugin#onConfig}.
+ */
+ public void start() {
+ log.info(
+ "Starting ExileClient for org={} (plugin={}, waiting for config)",
+ config.org(),
+ plugin.pluginName());
+
+ configPoller.scheduleAtFixedRate(
+ this::pollConfig, 0, configPollInterval.toSeconds(), TimeUnit.SECONDS);
+ }
+
+ private void pollConfig() {
+ try {
+ var newConfig = configService.getClientConfiguration();
+ if (newConfig == null) return;
+
+ boolean changed = lastConfig == null || !newConfig.equals(lastConfig);
+ lastConfig = newConfig;
+
+ if (changed) {
+ log.info(
+ "Config received from gate (org={}, configName={})",
+ newConfig.orgId(),
+ newConfig.configName());
+
+ boolean ready;
+ try {
+ ready = plugin.onConfig(newConfig);
+ } catch (Exception e) {
+ log.warn("Plugin {} rejected config: {}", plugin.pluginName(), e.getMessage());
+ pluginReady.set(false);
+ return;
+ }
+
+ if (!ready) {
+ log.warn("Plugin {} not ready — WorkStream will not start yet", plugin.pluginName());
+ pluginReady.set(false);
+ return;
+ }
+
+ pluginReady.set(true);
+ }
+
+ // Only start WorkStream + metrics once plugin has explicitly accepted a config.
+ if (pluginReady.get() && workStreamStarted.compareAndSet(false, true)) {
+ // Initialize MetricsManager now that we have org_id and certificate_name.
+ this.metricsManager =
+ new MetricsManager(
+ telemetryService,
+ telemetryClientId,
+ newConfig.orgId(),
+ config.certificateName(),
+ workStream::status);
+ workStream.setDurationRecorder(metricsManager::recordWorkDuration);
+ workStream.setMethodRecorder(metricsManager::recordMethodCall);
+ workStream.setReconnectRecorder(metricsManager::recordReconnectDuration);
+
+ log.info("Plugin {} ready, starting WorkStream", plugin.pluginName());
+ workStream.start();
+ }
+ } catch (Exception e) {
+ log.debug("Config poll failed ({}): {}", e.getClass().getSimpleName(), e.getMessage());
+ }
+ }
+
+ /** Returns the last polled config from the gate, or null if never polled. */
+ public ConfigService.ClientConfiguration lastPolledConfig() {
+ return lastConfig;
+ }
+
+ public StreamStatus streamStatus() {
+ return workStream.status();
+ }
+
+ public ExileConfig config() {
+ return config;
+ }
+
+ public Plugin plugin() {
+ return plugin;
+ }
+
+ public AgentService agents() {
+ return agentService;
+ }
+
+ public CallService calls() {
+ return callService;
+ }
+
+ public RecordingService recordings() {
+ return recordingService;
+ }
+
+ public ScrubListService scrubLists() {
+ return scrubListService;
+ }
+
+ public ConfigService config_() {
+ return configService;
+ }
+
+ public JourneyService journey() {
+ return journeyService;
+ }
+
+ /**
+ * The OTel Meter for registering custom metrics from plugins. Instruments created on this meter
+ * are exported alongside sati's built-in metrics to the gate TelemetryService. Returns null if
+ * the first config poll has not completed yet.
+ */
+ public Meter meter() {
+ var mm = metricsManager;
+ return mm != null ? mm.meter() : null;
+ }
+
+ @Override
+ public void close() {
+ log.info("Shutting down ExileClient");
+ configPoller.shutdownNow();
+ var appender = MemoryAppenderInstance.getInstance();
+ if (appender != null) {
+ appender.disableLogShipper();
+ }
+ var mm = metricsManager;
+ if (mm != null) mm.close();
+ workStream.close();
+ ChannelFactory.shutdown(serviceChannel);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private ExileConfig config;
+ private Plugin plugin;
+ private String clientName = "sati";
+ private String clientVersion = "unknown";
+ private int maxConcurrency = 5;
+ private List capabilities = new ArrayList<>();
+ private Duration configPollInterval = Duration.ofSeconds(10);
+
+ private Builder() {}
+
+ public Builder config(ExileConfig config) {
+ this.config = Objects.requireNonNull(config);
+ return this;
+ }
+
+ /** The plugin that handles jobs, events, and config validation. */
+ public Builder plugin(Plugin plugin) {
+ this.plugin = Objects.requireNonNull(plugin);
+ return this;
+ }
+
+ public Builder clientName(String clientName) {
+ this.clientName = Objects.requireNonNull(clientName);
+ return this;
+ }
+
+ public Builder clientVersion(String clientVersion) {
+ this.clientVersion = Objects.requireNonNull(clientVersion);
+ return this;
+ }
+
+ public Builder maxConcurrency(int maxConcurrency) {
+ if (maxConcurrency < 1) throw new IllegalArgumentException("maxConcurrency must be >= 1");
+ this.maxConcurrency = maxConcurrency;
+ return this;
+ }
+
+ /** How often to poll the gate for config updates. Default: 10 seconds. */
+ public Builder configPollInterval(Duration interval) {
+ this.configPollInterval = Objects.requireNonNull(interval);
+ return this;
+ }
+
+ public ExileClient build() {
+ Objects.requireNonNull(config, "config is required");
+ Objects.requireNonNull(plugin, "plugin is required");
+ return new ExileClient(this);
+ }
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/ExileConfig.java b/core/src/main/java/com/tcn/exile/ExileConfig.java
new file mode 100644
index 0000000..5c10a65
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/ExileConfig.java
@@ -0,0 +1,143 @@
+package com.tcn.exile;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+/**
+ * Immutable configuration for connecting to the Exile gate server.
+ *
+ * Holds mTLS credentials and server endpoint information. Built via {@link #builder()}.
+ */
+public final class ExileConfig {
+
+ private final String rootCert;
+ private final String publicCert;
+ private final String privateKey;
+ private final String apiHostname;
+ private final int apiPort;
+ private final String certificateName;
+
+ // Lazily derived from certificate.
+ private volatile String org;
+
+ private ExileConfig(Builder builder) {
+ this.rootCert = Objects.requireNonNull(builder.rootCert, "rootCert");
+ this.publicCert = Objects.requireNonNull(builder.publicCert, "publicCert");
+ this.privateKey = Objects.requireNonNull(builder.privateKey, "privateKey");
+ this.apiHostname = Objects.requireNonNull(builder.apiHostname, "apiHostname");
+ this.apiPort = builder.apiPort > 0 ? builder.apiPort : 443;
+ this.certificateName = builder.certificateName != null ? builder.certificateName : "";
+ }
+
+ public String rootCert() {
+ return rootCert;
+ }
+
+ public String publicCert() {
+ return publicCert;
+ }
+
+ public String privateKey() {
+ return privateKey;
+ }
+
+ public String apiHostname() {
+ return apiHostname;
+ }
+
+ public int apiPort() {
+ return apiPort;
+ }
+
+ /** The certificate name from the config file (may be empty). */
+ public String certificateName() {
+ return certificateName;
+ }
+
+ /** Extracts the organization name from the certificate CN field. Thread-safe. */
+ public String org() {
+ String result = org;
+ if (result == null) {
+ synchronized (this) {
+ result = org;
+ if (result == null) {
+ result = parseOrgFromCert();
+ org = result;
+ }
+ }
+ }
+ return result;
+ }
+
+ private String parseOrgFromCert() {
+ try {
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ X509Certificate cert =
+ (X509Certificate)
+ cf.generateCertificate(
+ new ByteArrayInputStream(publicCert.getBytes(StandardCharsets.UTF_8)));
+ String dn = cert.getSubjectX500Principal().getName();
+ for (String part : dn.split(",")) {
+ String trimmed = part.trim();
+ if (trimmed.startsWith("O=")) {
+ return trimmed.substring(2);
+ }
+ }
+ return "";
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to parse organization from certificate", e);
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private String rootCert;
+ private String publicCert;
+ private String privateKey;
+ private String apiHostname;
+ private int apiPort;
+ private String certificateName;
+
+ private Builder() {}
+
+ public Builder rootCert(String rootCert) {
+ this.rootCert = rootCert;
+ return this;
+ }
+
+ public Builder publicCert(String publicCert) {
+ this.publicCert = publicCert;
+ return this;
+ }
+
+ public Builder privateKey(String privateKey) {
+ this.privateKey = privateKey;
+ return this;
+ }
+
+ public Builder apiHostname(String apiHostname) {
+ this.apiHostname = apiHostname;
+ return this;
+ }
+
+ public Builder certificateName(String certificateName) {
+ this.certificateName = certificateName;
+ return this;
+ }
+
+ public Builder apiPort(int apiPort) {
+ this.apiPort = apiPort;
+ return this;
+ }
+
+ public ExileConfig build() {
+ return new ExileConfig(this);
+ }
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/StreamStatus.java b/core/src/main/java/com/tcn/exile/StreamStatus.java
new file mode 100644
index 0000000..dcf030f
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/StreamStatus.java
@@ -0,0 +1,49 @@
+package com.tcn.exile;
+
+import java.time.Instant;
+
+/**
+ * Snapshot of the bidirectional work stream's current state.
+ *
+ *
Obtain via {@link ExileClient#streamStatus()}.
+ */
+public record StreamStatus(
+ /** Current connection phase. */
+ Phase phase,
+ /** Server-assigned client ID (set after REGISTERED, null before). */
+ String clientId,
+ /** When the current stream connection was established (null if not connected). */
+ Instant connectedSince,
+ /** When the last disconnect occurred (null if never disconnected). */
+ Instant lastDisconnect,
+ /** Last error message (null if no error). */
+ String lastError,
+ /** Number of work items currently being processed. */
+ int inflight,
+ /** Total work items completed (results + acks) since start. */
+ long completedTotal,
+ /** Total work items that failed since start. */
+ long failedTotal,
+ /** Total stream reconnection attempts since start. */
+ long reconnectAttempts) {
+
+ public enum Phase {
+ /** Client created but start() not yet called. */
+ IDLE,
+ /** Connecting to the gate server. */
+ CONNECTING,
+ /** Stream open, Register sent, waiting for Registered response. */
+ REGISTERING,
+ /** Registered and actively pulling/processing work. */
+ ACTIVE,
+ /** Stream disconnected, waiting to reconnect (backoff). */
+ RECONNECTING,
+ /** Client has been closed. */
+ CLOSED
+ }
+
+ /** True if the stream is connected and processing work. */
+ public boolean isHealthy() {
+ return phase == Phase.ACTIVE;
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/config/Config.java b/core/src/main/java/com/tcn/exile/config/Config.java
deleted file mode 100644
index 8d27bdb..0000000
--- a/core/src/main/java/com/tcn/exile/config/Config.java
+++ /dev/null
@@ -1,483 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.config;
-
-import com.tcn.exile.gateclients.UnconfiguredException;
-import io.micronaut.core.type.Argument;
-import io.micronaut.serde.ObjectMapper;
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.validation.constraints.NotEmpty;
-import jakarta.validation.constraints.NotNull;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Concrete implementation of ConfigInterface that stores configuration data. */
-@Serdeable
-public class Config {
- private static final List fieldNames =
- List.of(
- "ca_certificate",
- "certificate",
- "private_key",
- "fingerprint_sha256",
- "fingerprint_sha256_string",
- "api_endpoint",
- "certificate_name",
- "certificate_description");
- private static final Logger log = LoggerFactory.getLogger(Config.class);
-
- private boolean unconfigured;
- private String rootCert;
- private String publicCert;
- private String privateKey;
- private String fingerprintSha256;
- private String fingerprintSha256String;
- private String apiEndpoint;
- private String certificateName;
- private String certificateDescription;
- private String org = null;
-
- /** Default constructor - initializes as unconfigured. */
- public Config() {
- this.unconfigured = true;
- }
-
- /**
- * Copy constructor
- *
- * @param source The source config to copy from
- */
- public Config(Config source) {
- if (source != null) {
- this.unconfigured = source.isUnconfigured();
- this.rootCert = source.getRootCert();
- this.publicCert = source.getPublicCert();
- this.privateKey = source.getPrivateKey();
- this.fingerprintSha256 = source.getFingerprintSha256();
- this.fingerprintSha256String = source.getFingerprintSha256String();
- this.apiEndpoint = source.getApiEndpoint();
- this.certificateName = source.getCertificateName();
- this.certificateDescription = source.getCertificateDescription();
- }
- }
-
- public static Optional of(@NotNull byte[] data, @NotNull ObjectMapper objectMapper) {
- try {
- var map =
- objectMapper.readValue(
- Base64.getDecoder().decode(data), Argument.mapOf(String.class, String.class));
- if (!map.keySet().containsAll(fieldNames)) {
- log.error("Invalid certificate data format");
- return Optional.empty();
- }
- var ret =
- Optional.of(
- Config.builder()
- .rootCert(map.get("ca_certificate"))
- .publicCert(map.get("certificate"))
- .privateKey(map.get("private_key"))
- .fingerprintSha256(map.get("fingerprint_sha256"))
- .fingerprintSha256String(map.get("fingerprint_sha256_string"))
- .apiEndpoint(map.get("api_endpoint"))
- .certificateName(map.get("certificate_name"))
- .certificateDescription(map.get("certificate_description"))
- .build());
- if (ret.get().getOrg() == null) {
- log.error("Parsing certificate failed");
- return Optional.empty();
- }
- return ret;
- } catch (IOException e) {
- log.debug("Parsing error", e);
- return Optional.empty();
- }
- }
-
- /**
- * Constructor that takes a base64 encoded JSON string and parses it.
- *
- * @param base64EncodedJson The base64 encoded JSON string to parse
- * @param objectMapper The ObjectMapper to use for JSON deserialization
- * @throws UnconfiguredException If parsing fails
- */
- public Config(String base64EncodedJson, ObjectMapper objectMapper) throws UnconfiguredException {
- if (base64EncodedJson == null || base64EncodedJson.isEmpty()) {
- throw new UnconfiguredException("Base64 encoded JSON string cannot be null or empty");
- }
-
- try {
- byte[] jsonBytes = Base64.getDecoder().decode(base64EncodedJson);
- @SuppressWarnings("unchecked") // Suppress warning for Map cast
- Map jsonMap = objectMapper.readValue(jsonBytes, Map.class);
-
- this.rootCert = jsonMap.get("ca_certificate");
- this.publicCert = jsonMap.get("certificate");
- this.privateKey = jsonMap.get("private_key");
- this.fingerprintSha256 = jsonMap.get("fingerprint_sha256");
- this.fingerprintSha256String = jsonMap.get("fingerprint_sha256_string");
- this.apiEndpoint = jsonMap.get("api_endpoint");
- this.certificateName = jsonMap.get("certificate_name");
- this.certificateDescription = jsonMap.get("certificate_description");
- this.unconfigured = false;
-
- log.debug("Parsed base64 encoded JSON successfully");
- } catch (IOException e) {
- throw new UnconfiguredException("Failed to parse JSON", e);
- } catch (IllegalArgumentException e) {
- throw new UnconfiguredException("Invalid base64 string", e);
- }
- }
-
- /**
- * Private constructor for the Builder pattern.
- *
- * @param builder The builder instance to initialize from.
- */
- private Config(Builder builder) {
- this.unconfigured = builder.unconfigured;
- this.rootCert = builder.rootCert;
- this.publicCert = builder.publicCert;
- this.privateKey = builder.privateKey;
- this.fingerprintSha256 = builder.fingerprintSha256;
- this.fingerprintSha256String = builder.fingerprintSha256String;
- this.apiEndpoint = builder.apiEndpoint;
- this.certificateName = builder.certificateName;
- this.certificateDescription = builder.certificateDescription;
- }
-
- /**
- * Static factory method to create a Config from a base64 encoded JSON string.
- *
- * @param base64EncodedJson The base64 encoded JSON string to parse
- * @param objectMapper The ObjectMapper to use for JSON deserialization
- * @return A new Config instance
- * @throws UnconfiguredException If parsing fails
- */
- public static Config fromBase64Json(String base64EncodedJson, ObjectMapper objectMapper)
- throws UnconfiguredException {
- return new Config(base64EncodedJson, objectMapper);
- }
-
- /**
- * Static factory method to get a new Builder instance.
- *
- * @return A new Builder instance.
- */
- public static Builder builder() {
- return new Builder();
- }
-
- // --- Getters ---
-
- public String getCertificateDescription() {
- return certificateDescription;
- }
-
- public String getCertificateName() {
- return certificateName;
- }
-
- public String getApiEndpoint() {
- return apiEndpoint;
- }
-
- public String getFingerprintSha256() {
- return fingerprintSha256;
- }
-
- public String getFingerprintSha256String() {
- return fingerprintSha256String;
- }
-
- public boolean isUnconfigured() {
- return unconfigured;
- }
-
- public String getRootCert() {
- return rootCert;
- }
-
- public String getPublicCert() {
- return publicCert;
- }
-
- public String getPrivateKey() {
- return privateKey;
- }
-
- // --- Setters (Potentially make these package-private or remove if Builder is preferred) ---
- // Note: Setters are kept public for now to maintain compatibility with ConfigEvent.Builder and
- // direct usage.
- // Consider changing visibility if strict immutability via Builder is desired.
-
- public void setCertificateDescription(String certificateDescription) {
- this.certificateDescription = certificateDescription;
- }
-
- public void setCertificateName(String certificateName) {
- this.certificateName = certificateName;
- }
-
- public void setApiEndpoint(String apiEndpoint) {
- this.apiEndpoint = apiEndpoint;
- }
-
- public void setFingerprintSha256(String fingerprintSha256) {
- this.fingerprintSha256 = fingerprintSha256;
- }
-
- public void setFingerprintSha256String(String fingerprintSha256String) {
- this.fingerprintSha256String = fingerprintSha256String;
- }
-
- public void setUnconfigured(boolean unconfigured) {
- this.unconfigured = unconfigured;
- }
-
- public void setRootCert(String rootCert) {
- this.rootCert = rootCert;
- }
-
- public void setPublicCert(String publicCert) {
- this.publicCert = publicCert;
- }
-
- public void setPrivateKey(String privateKey) {
- this.privateKey = privateKey;
- }
-
- // --- Derived Data Methods ---
-
- public String getOrg() {
- if (this.org != null) {
- return this.org;
- }
- try {
- if (publicCert == null || publicCert.isBlank()) {
- return null;
- }
- CertificateFactory cf = CertificateFactory.getInstance("X.509");
- ByteArrayInputStream bais = new ByteArrayInputStream(publicCert.getBytes());
- X509Certificate cert = (X509Certificate) cf.generateCertificate(bais);
- String dn = cert.getSubjectX500Principal().getName();
- log.debug("Certificate Subject: " + dn);
-
- // Extract CN from DN
- for (String part : dn.split(",")) {
- if (part.trim().startsWith("O=")) {
- this.org = part.substring(2).trim();
- return this.org;
- }
- }
- return null;
- } catch (CertificateException e) {
- log.error("Error parsing certificate for getOrg: {}", e.getMessage());
- return null;
- } catch (Exception e) {
- log.error("Unexpected error in getOrg", e);
- return null;
- }
- }
-
- public String getApiHostname() throws UnconfiguredException {
- if (apiEndpoint == null || apiEndpoint.isBlank()) {
- throw new UnconfiguredException("API endpoint is not set");
- }
- try {
- return new URL(apiEndpoint).getHost();
- } catch (MalformedURLException e) {
- throw new UnconfiguredException("Invalid API endpoint URL: " + apiEndpoint, e);
- }
- }
-
- public int getApiPort() throws UnconfiguredException {
- if (apiEndpoint == null || apiEndpoint.isBlank()) {
- throw new UnconfiguredException("API endpoint is not set");
- }
- try {
- var url = new URL(apiEndpoint);
- if (url.getPort() == -1) {
- // Use default HTTPS port if not specified
- return 443;
- }
- return url.getPort();
- } catch (MalformedURLException e) {
- throw new UnconfiguredException("Invalid API endpoint URL: " + apiEndpoint, e);
- }
- }
-
- public Date getExpirationDate() {
- var certStr = this.getPublicCert();
- if (certStr == null || certStr.isBlank()) {
- return null;
- }
- try {
- CertificateFactory cf = CertificateFactory.getInstance("X.509");
- X509Certificate myCert =
- (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certStr.getBytes()));
- return myCert.getNotAfter();
- } catch (CertificateException e) {
- log.error("Error parsing certificate for expiration date: {}", e.getMessage());
- } catch (Exception e) {
- log.error("Unexpected error getting expiration date", e);
- }
- return null;
- }
-
- // --- Object Methods ---
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (o == null || !(o instanceof Config)) return false;
-
- Config that = (Config) o;
-
- // Consider adding more fields for a more robust equality check if needed
- if (unconfigured != that.isUnconfigured()) return false;
- if (!Objects.equals(apiEndpoint, that.getApiEndpoint())) return false;
- if (!Objects.equals(getOrg(), that.getOrg())) return false; // Derived, might be slow
- return Objects.equals(publicCert, that.getPublicCert());
- }
-
- @Override
- public int hashCode() {
- // Consider adding more fields for a more robust hash code if needed
- return Objects.hash(unconfigured, apiEndpoint, getOrg(), publicCert);
- }
-
- @Override
- public String toString() {
- return "Config{"
- + "unconfigured="
- + unconfigured
- + ", apiEndpoint='"
- + apiEndpoint
- + '\''
- + ", certificateName='"
- + certificateName
- + '\''
- + ", org='"
- + getOrg()
- + "',..."
- + // Be mindful of potential null from getOrg()
- '}';
- }
-
- // --- Builder Class ---
-
- public static class Builder {
- private boolean unconfigured = true; // Default to unconfigured
- private String rootCert;
- private boolean rootCertSet = false;
- private String publicCert;
- private boolean publicCertSet = false;
- private String privateKey;
- private boolean privateKeySet = false;
- private String fingerprintSha256;
- private boolean fingerprintSha256Set = false;
- private String fingerprintSha256String;
- private boolean fingerprintSha256StringSet = false;
- private String apiEndpoint;
- private boolean apiEndpointSet = false;
- private String certificateName;
- private boolean certificateNameSet = false;
- private String certificateDescription;
- private boolean certificateDescriptionSet = false;
-
- public Builder rootCert(@NotEmpty String rootCert) {
- this.rootCert = rootCert;
- this.rootCertSet = true;
- return this;
- }
-
- public Builder publicCert(@NotEmpty String publicCert) {
- this.publicCert = publicCert;
- this.publicCertSet = true;
- return this;
- }
-
- public Builder privateKey(@NotEmpty String privateKey) {
- this.privateKey = privateKey;
- this.privateKeySet = true;
- return this;
- }
-
- public Builder fingerprintSha256(@NotEmpty String fingerprintSha256) {
- this.fingerprintSha256 = fingerprintSha256;
- this.fingerprintSha256Set = true;
- return this;
- }
-
- public Builder fingerprintSha256String(@NotEmpty String fingerprintSha256String) {
- this.fingerprintSha256String = fingerprintSha256String;
- this.fingerprintSha256Set = true;
- return this;
- }
-
- public Builder apiEndpoint(@NotEmpty String apiEndpoint) {
- this.apiEndpoint = apiEndpoint;
- this.apiEndpointSet = true;
- return this;
- }
-
- public Builder certificateName(@NotEmpty String certificateName) {
- this.certificateName = certificateName;
- this.certificateNameSet = true;
- return this;
- }
-
- public Builder certificateDescription(String certificateDescription) {
- this.certificateDescription = certificateDescription; // Can be null or empty
- this.certificateDescriptionSet = true;
- return this;
- }
-
- /**
- * Builds the Config object. It automatically sets unconfigured to false if essential fields
- * (e.g., apiEndpoint, publicCert) are set. Add more validation here if needed.
- *
- * @return A new Config instance.
- */
- public Config build() {
- this.unconfigured =
- !(this.rootCertSet
- && this.publicCertSet
- && this.privateKeySet
- && this.fingerprintSha256Set
- && this.fingerprintSha256StringSet
- && this.apiEndpointSet
- && this.certificateNameSet
- && this.certificateDescriptionSet);
-
- // Automatically mark as configured if key fields are provided
- if (this.unconfigured && apiEndpoint != null && publicCert != null) {
- log.debug("Builder automatically marking config as configured based on provided fields.");
- this.unconfigured = false;
- }
- return new Config(this);
- }
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java b/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java
deleted file mode 100644
index 2f6b7a2..0000000
--- a/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java
+++ /dev/null
@@ -1,1652 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.config;
-
-import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult;
-import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult.*;
-import ch.qos.logback.classic.LoggerContext;
-import com.google.protobuf.Timestamp;
-import com.tcn.exile.plugin.PluginInterface;
-import io.micronaut.context.ApplicationContext;
-import jakarta.inject.Singleton;
-import java.io.File;
-import java.io.IOException;
-import java.lang.management.*;
-import java.lang.reflect.Method;
-import java.net.InetAddress;
-import java.net.UnknownHostException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.time.Instant;
-import java.util.*;
-import java.util.Base64;
-import java.util.regex.Pattern;
-import javax.management.MBeanServer;
-import javax.management.ObjectName;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class DiagnosticsService {
- private static final Logger log = LoggerFactory.getLogger(DiagnosticsService.class);
-
- private static final Pattern DOCKER_PATTERN =
- Pattern.compile(".*docker.*", Pattern.CASE_INSENSITIVE);
- private static final Pattern KUBERNETES_PATTERN =
- Pattern.compile(".*k8s.*|.*kube.*", Pattern.CASE_INSENSITIVE);
-
- private final ApplicationContext applicationContext;
-
- public DiagnosticsService(ApplicationContext applicationContext) {
- this.applicationContext = applicationContext;
- }
-
- // Helper class to hold log collection results
- private static class LogCollectionResult {
- final List logs;
- final boolean success;
- final String errorMessage;
-
- LogCollectionResult(List logs, boolean success, String errorMessage) {
- this.logs = logs != null ? logs : new ArrayList<>();
- this.success = success;
- this.errorMessage = errorMessage;
- }
-
- static LogCollectionResult success(List logs) {
- return new LogCollectionResult(logs, true, null);
- }
-
- static LogCollectionResult failure(String errorMessage) {
- return new LogCollectionResult(null, false, errorMessage);
- }
- }
-
- public DiagnosticsResult collectSystemDiagnostics() {
- log.info("Collecting comprehensive system diagnostics...");
-
- try {
- Instant now = Instant.now();
- Timestamp timestamp =
- Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano()).build();
-
- // Collect Hikari metrics, config details, and event stream stats
- List hikariPoolMetrics = collectHikariMetrics();
- ConfigDetails configDetails = collectConfigDetails();
- EventStreamStats eventStreamStats = collectEventStreamStats();
-
- return DiagnosticsResult.newBuilder()
- .setTimestamp(timestamp)
- .setHostname(getHostname())
- .setOperatingSystem(collectOperatingSystemInfo())
- .setJavaRuntime(collectJavaRuntimeInfo())
- .setHardware(collectHardwareInfo())
- .setMemory(collectMemoryInfo())
- .addAllStorage(collectStorageInfo())
- .setContainer(collectContainerInfo())
- .setEnvironmentVariables(collectEnvironmentVariables())
- .setSystemProperties(collectSystemProperties())
- .addAllHikariPoolMetrics(hikariPoolMetrics)
- .setConfigDetails(configDetails)
- .setEventStreamStats(eventStreamStats)
- .build();
- } catch (Exception e) {
- log.error("Error collecting system diagnostics", e);
- throw new RuntimeException("Failed to collect system diagnostics", e);
- }
- }
-
- /**
- * Collects tenant logs and returns them in protobuf format for plugin responses. This method
- * handles time range filtering and log level detection.
- *
- * @param listTenantLogsRequest The request containing optional time range filtering
- * @return ListTenantLogsResult in protobuf format ready for submission to gate
- */
- public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- collectTenantLogs(
- build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.ListTenantLogsRequest
- listTenantLogsRequest) {
- log.debug("Collecting tenant logs from memory appender...");
-
- long startTime = System.currentTimeMillis();
-
- try {
- // Extract time range from request if provided
- Long startTimeMs = null;
- Long endTimeMs = null;
- if (listTenantLogsRequest.hasTimeRange()) {
- // Use the exact same timestamp conversion logic that was working before
- startTimeMs =
- listTenantLogsRequest.getTimeRange().getStartTime().getSeconds() * 1000
- + listTenantLogsRequest.getTimeRange().getStartTime().getNanos() / 1000000;
- endTimeMs =
- listTenantLogsRequest.getTimeRange().getEndTime().getSeconds() * 1000
- + listTenantLogsRequest.getTimeRange().getEndTime().getNanos() / 1000000;
- log.debug(
- "Filtering logs with time range: {} to {} (timestamps: {} to {})",
- listTenantLogsRequest.getTimeRange().getStartTime(),
- listTenantLogsRequest.getTimeRange().getEndTime(),
- startTimeMs,
- endTimeMs);
- }
-
- // Collect logs using common method
- LogCollectionResult result = collectLogsFromMemoryAppender(startTimeMs, endTimeMs);
-
- // Create protobuf result
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.Builder
- resultBuilder =
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .newBuilder();
-
- if (result.success && !result.logs.isEmpty()) {
- // Create a single log group with all logs
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .Builder
- logGroupBuilder =
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.newBuilder()
- .setName("logGroups/memory-logs")
- .addAllLogs(result.logs);
-
- // Set time range
- if (listTenantLogsRequest.hasTimeRange()) {
- logGroupBuilder.setTimeRange(listTenantLogsRequest.getTimeRange());
- } else {
- // Fallback to current time
- long now = System.currentTimeMillis();
- logGroupBuilder.setTimeRange(
- build.buf.gen.tcnapi.exile.gate.v2.TimeRange.newBuilder()
- .setStartTime(createTimestamp(now))
- .setEndTime(createTimestamp(now))
- .build());
- }
-
- // Detect log levels from the actual logs
- Map<
- String,
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel>
- detectedLevels = detectLogLevelsByLogger();
-
- // If no loggers were detected, provide a default
- if (detectedLevels.isEmpty()) {
- detectedLevels.put(
- "memory",
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.INFO);
- }
-
- detectedLevels.forEach(logGroupBuilder::putLogLevels);
-
- resultBuilder.addLogGroups(logGroupBuilder.build());
- }
-
- long duration = System.currentTimeMillis() - startTime;
- log.debug("Successfully collected tenant logs in {}ms", duration);
-
- return resultBuilder.build();
- } catch (Exception e) {
- log.error("Error collecting tenant logs: {}", e.getMessage(), e);
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .newBuilder()
- .build();
- }
- }
-
- /**
- * Collects tenant logs from the MemoryAppender and returns them as a serializable result. Uses
- * reflection to access MemoryAppender classes since they're in a different module.
- *
- * @return TenantLogsResult containing the collected logs
- */
- public com.tcn.exile.models.TenantLogsResult collectSerdeableTenantLogs() {
- return collectSerdeableTenantLogs(null, null);
- }
-
- /**
- * Collects tenant logs from the MemoryAppender within a specific time range.
- *
- * @param startTimeMs Start time in milliseconds since epoch, or null for no start limit
- * @param endTimeMs End time in milliseconds since epoch, or null for no end limit
- * @return TenantLogsResult containing the collected logs
- */
- public com.tcn.exile.models.TenantLogsResult collectSerdeableTenantLogs(
- Long startTimeMs, Long endTimeMs) {
- log.debug("Collecting tenant logs from memory appender...");
-
- try {
- // Collect logs using common method
- LogCollectionResult result = collectLogsFromMemoryAppender(startTimeMs, endTimeMs);
-
- // Create the result object
- com.tcn.exile.models.TenantLogsResult tenantLogsResult =
- new com.tcn.exile.models.TenantLogsResult();
- List logGroups = new ArrayList<>();
-
- if (result.success && !result.logs.isEmpty()) {
- // Create a single log group with all logs
- com.tcn.exile.models.TenantLogsResult.LogGroup logGroup =
- new com.tcn.exile.models.TenantLogsResult.LogGroup();
- logGroup.setName("logGroups/memory-logs");
- logGroup.setLogs(result.logs);
-
- // Set time range based on actual parameters used for filtering
- com.tcn.exile.models.TenantLogsResult.LogGroup.TimeRange timeRange =
- new com.tcn.exile.models.TenantLogsResult.LogGroup.TimeRange();
-
- if (startTimeMs != null && endTimeMs != null) {
- // Use the actual time parameters that were used for filtering
- timeRange.setStartTime(java.time.Instant.ofEpochMilli(startTimeMs));
- timeRange.setEndTime(java.time.Instant.ofEpochMilli(endTimeMs));
- } else {
- java.time.Instant now = java.time.Instant.now();
- java.time.Instant oneHourAgo = now.minusSeconds(600);
- timeRange.setStartTime(oneHourAgo);
- timeRange.setEndTime(now);
- }
- logGroup.setTimeRange(timeRange);
-
- // Detect log levels by logger name from the actual logs
- Map logLevels =
- detectSerializableLogLevelsByLogger();
-
- logGroup.setLogLevels(logLevels);
-
- logGroups.add(logGroup);
- log.info("Created log group with {} log entries", result.logs.size());
- } else {
- log.info("No logs found in memory appender");
- }
-
- tenantLogsResult.setLogGroups(logGroups);
- tenantLogsResult.setNextPageToken(""); // No pagination for now
-
- return tenantLogsResult;
- } catch (Exception e) {
- log.error("Error collecting tenant logs", e);
- // Return empty result on error
- com.tcn.exile.models.TenantLogsResult result = new com.tcn.exile.models.TenantLogsResult();
- result.setLogGroups(new ArrayList<>());
- result.setNextPageToken("");
- return result;
- }
- }
-
- /**
- * Detects log levels by logger name from the LoggerContext system configuration. This method
- * retrieves actual configured log levels rather than parsing from log output.
- *
- * @return Map of logger names to their configured log levels
- */
- private Map<
- String,
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel>
- detectLogLevelsByLogger() {
- Map<
- String,
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel>
- result = new HashMap<>();
-
- try {
- // Get the logback logger context
- LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
-
- // Get all configured loggers
- List loggers = loggerContext.getLoggerList();
-
- for (ch.qos.logback.classic.Logger logger : loggers) {
- String loggerName = logger.getName();
- ch.qos.logback.classic.Level level = logger.getLevel();
-
- // Skip loggers without explicit levels (they inherit from parent)
- if (level != null) {
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel
- protobufLevel = convertLogbackLevelToProtobuf(level);
- result.put(loggerName, protobufLevel);
- } else {
- // For loggers without explicit levels, check effective level
- ch.qos.logback.classic.Level effectiveLevel = logger.getEffectiveLevel();
- if (effectiveLevel != null) {
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel
- protobufLevel = convertLogbackLevelToProtobuf(effectiveLevel);
- result.put(loggerName + " (inherited)", protobufLevel);
- }
- }
- }
-
- // Always include root logger information
- ch.qos.logback.classic.Logger rootLogger =
- loggerContext.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);
- if (rootLogger != null) {
- ch.qos.logback.classic.Level rootLevel = rootLogger.getLevel();
- if (rootLevel != null) {
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel
- protobufLevel = convertLogbackLevelToProtobuf(rootLevel);
- result.put("ROOT", protobufLevel);
- }
- }
-
- } catch (Exception e) {
- log.warn("Error detecting log levels from LoggerContext: {}", e.getMessage(), e);
- // Fallback to default
- result.put(
- "system.default",
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel.INFO);
- }
-
- return result;
- }
-
- /**
- * Detects log levels by logger name from the LoggerContext system configuration for serializable
- * results. This method retrieves actual configured log levels rather than parsing from log
- * output.
- *
- * @return Map of logger names to their configured log levels
- */
- private Map
- detectSerializableLogLevelsByLogger() {
- Map result = new HashMap<>();
-
- try {
- // Get the logback logger context
- LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
-
- // Get all configured loggers
- List loggers = loggerContext.getLoggerList();
-
- for (ch.qos.logback.classic.Logger logger : loggers) {
- String loggerName = logger.getName();
- ch.qos.logback.classic.Level level = logger.getLevel();
-
- // Skip loggers without explicit levels (they inherit from parent)
- if (level != null) {
- com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel =
- convertLogbackLevelToSerializable(level);
- result.put(loggerName, serializableLevel);
- } else {
- // For loggers without explicit levels, check effective level
- ch.qos.logback.classic.Level effectiveLevel = logger.getEffectiveLevel();
- if (effectiveLevel != null) {
- com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel =
- convertLogbackLevelToSerializable(effectiveLevel);
- result.put(loggerName + " (inherited)", serializableLevel);
- }
- }
- }
-
- // Always include root logger information
- ch.qos.logback.classic.Logger rootLogger =
- loggerContext.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME);
- if (rootLogger != null) {
- ch.qos.logback.classic.Level rootLevel = rootLogger.getLevel();
- if (rootLevel != null) {
- com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel =
- convertLogbackLevelToSerializable(rootLevel);
- result.put("ROOT", serializableLevel);
- }
- }
-
- } catch (Exception e) {
- log.warn("Error detecting log levels from LoggerContext: {}", e.getMessage(), e);
- // Fallback to default
- result.put("system.default", com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO);
- }
-
- return result;
- }
-
- /** Converts logback Level to protobuf LogLevel enum. */
- private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup
- .LogLevel
- convertLogbackLevelToProtobuf(ch.qos.logback.classic.Level logbackLevel) {
- switch (logbackLevel.toInt()) {
- case ch.qos.logback.classic.Level.TRACE_INT:
- case ch.qos.logback.classic.Level.DEBUG_INT:
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.DEBUG;
- case ch.qos.logback.classic.Level.INFO_INT:
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.INFO;
- case ch.qos.logback.classic.Level.WARN_INT:
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.WARNING;
- case ch.qos.logback.classic.Level.ERROR_INT:
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.ERROR;
- default:
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult
- .LogGroup.LogLevel.INFO;
- }
- }
-
- /** Converts logback Level to serializable LogLevel enum. */
- private com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel convertLogbackLevelToSerializable(
- ch.qos.logback.classic.Level logbackLevel) {
- switch (logbackLevel.toInt()) {
- case ch.qos.logback.classic.Level.TRACE_INT:
- case ch.qos.logback.classic.Level.DEBUG_INT:
- return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.DEBUG;
- case ch.qos.logback.classic.Level.INFO_INT:
- return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO;
- case ch.qos.logback.classic.Level.WARN_INT:
- return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.WARNING;
- case ch.qos.logback.classic.Level.ERROR_INT:
- return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.ERROR;
- default:
- return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO;
- }
- }
-
- /**
- * Sets the log level for a specific logger and returns the result in protobuf format for plugin
- * responses. This method handles dynamic log level changes during runtime and includes proper
- * tenant information.
- *
- * @param setLogLevelRequest The request containing the logger name and new log level
- * @param tenantKey The tenant identifier for proper tenant information
- * @return SetLogLevelResult in protobuf format ready for submission to gate
- */
- public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult
- setLogLevelWithTenant(
- build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest
- setLogLevelRequest,
- String tenantKey) {
- log.debug(
- "Setting log level for logger: {} to level: {} (tenant: {})",
- setLogLevelRequest.getLog(),
- setLogLevelRequest.getLogLevel(),
- tenantKey);
-
- try {
- // Get the logback logger context
- LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
-
- String loggerName = setLogLevelRequest.getLog();
-
- // Handle special case for root logger
- if ("ROOT".equalsIgnoreCase(loggerName) || loggerName.isEmpty()) {
- loggerName = ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME;
- }
-
- // Get the target logger
- ch.qos.logback.classic.Logger targetLogger = loggerContext.getLogger(loggerName);
-
- if (targetLogger == null) {
- log.warn("Logger '{}' not found", loggerName);
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult
- .newBuilder()
- .setTenant(createTenantInfoWithKey(tenantKey, "Logger not found: " + loggerName))
- .build();
- }
-
- // Convert the protobuf log level to logback level
- ch.qos.logback.classic.Level newLevel;
- try {
- newLevel = convertProtobufLevelToLogbackLevel(setLogLevelRequest.getLogLevel());
- } catch (IllegalArgumentException e) {
- log.warn("Invalid log level: {}", setLogLevelRequest.getLogLevel());
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult
- .newBuilder()
- .setTenant(
- createTenantInfoWithKey(
- tenantKey, "Invalid log level: " + setLogLevelRequest.getLogLevel()))
- .build();
- }
-
- // Get the old level for logging
- String oldLevel =
- targetLogger.getLevel() != null ? targetLogger.getLevel().toString() : "INHERITED";
-
- // Set the new level
- targetLogger.setLevel(newLevel);
-
- log.info(
- "Successfully changed log level for '{}' from '{}' to '{}' (tenant: {})",
- loggerName,
- oldLevel,
- newLevel.toString(),
- tenantKey);
-
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult
- .newBuilder()
- .setTenant(
- createTenantInfoWithKey(
- tenantKey,
- "Successfully changed log level for '"
- + loggerName
- + "' from '"
- + oldLevel
- + "' to '"
- + newLevel.toString()
- + "'"))
- .build();
-
- } catch (Exception e) {
- log.error(
- "Error setting log level for logger '{}' (tenant: {}): {}",
- setLogLevelRequest.getLog(),
- tenantKey,
- e.getMessage(),
- e);
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult
- .newBuilder()
- .setTenant(
- createTenantInfoWithKey(tenantKey, "Failed to set log level: " + e.getMessage()))
- .build();
- }
- }
-
- /**
- * Sets the log level for a specific logger and returns the result in protobuf format for plugin
- * responses. This method handles dynamic log level changes during runtime.
- *
- * @param setLogLevelRequest The request containing the logger name and new log level
- * @return SetLogLevelResult in protobuf format ready for submission to gate
- * @deprecated Use setLogLevelWithTenant instead for proper tenant information
- */
- @Deprecated
- public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult setLogLevel(
- build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest setLogLevelRequest) {
- return setLogLevelWithTenant(setLogLevelRequest, getHostname());
- }
-
- /**
- * Creates a Tenant object for the SetLogLevelResult with proper tenant key.
- *
- * @param tenantKey The tenant identifier
- * @param statusMessage A status message to include in the name field
- * @return Tenant object with system information
- */
- private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant
- createTenantInfoWithKey(String tenantKey, String statusMessage) {
- Instant now = Instant.now();
- Timestamp updateTime =
- Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano()).build();
-
- // Get version information
- String satiVersion = "Unknown";
- String pluginVersion = "Unknown";
- try {
- satiVersion = com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion();
- } catch (Exception e) {
- log.debug("Could not get SATI version: {}", e.getMessage());
- }
-
- try {
- // Try to get plugin version from system properties or manifest
- String implVersion = this.getClass().getPackage().getImplementationVersion();
- if (implVersion != null) {
- pluginVersion = implVersion;
- }
- } catch (Exception e) {
- log.debug("Could not get plugin version: {}", e.getMessage());
- }
-
- // Use tenant key as the primary identifier
- String tenantName = tenantKey != null ? tenantKey : getHostname();
- if (statusMessage != null && !statusMessage.isEmpty()) {
- tenantName = tenantName + " (" + statusMessage + ")";
- }
-
- return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant
- .newBuilder()
- .setName(tenantName)
- .setSatiVersion(satiVersion)
- .setPluginVersion(pluginVersion)
- .setUpdateTime(updateTime)
- .setConnectedGate(getHostname()) // Use hostname as connected gate info
- .build();
- }
-
- /**
- * Creates a Tenant object for the SetLogLevelResult. Since we don't have access to the actual
- * tenant context in DiagnosticsService, we create a basic tenant info with available system
- * information.
- *
- * @param statusMessage A status message to include in the name field
- * @return Tenant object with system information
- * @deprecated Use createTenantInfoWithKey instead for proper tenant information
- */
- @Deprecated
- private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant
- createTenantInfo(String statusMessage) {
- return createTenantInfoWithKey(null, statusMessage);
- }
-
- /** Converts protobuf LogLevel enum to logback Level. */
- private ch.qos.logback.classic.Level convertProtobufLevelToLogbackLevel(
- build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest.LogLevel
- protobufLevel) {
- switch (protobufLevel) {
- case DEBUG:
- return ch.qos.logback.classic.Level.DEBUG;
- case INFO:
- return ch.qos.logback.classic.Level.INFO;
- case WARNING:
- return ch.qos.logback.classic.Level.WARN;
- case ERROR:
- return ch.qos.logback.classic.Level.ERROR;
- case FATAL:
- return ch.qos.logback.classic.Level.ERROR; // Logback doesn't have FATAL, map to ERROR
- default:
- throw new IllegalArgumentException("Unknown protobuf log level: " + protobufLevel);
- }
- }
-
- private String getHostname() {
- try {
- return InetAddress.getLocalHost().getHostName();
- } catch (UnknownHostException e) {
- return System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "unknown";
- }
- }
-
- private OperatingSystem collectOperatingSystemInfo() {
- OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
-
- long totalPhysicalMemory = -1;
- long availablePhysicalMemory = -1;
- long totalSwapSpace = -1;
- long availableSwapSpace = -1;
-
- // Try to get extended info if available
- try {
- if (osBean.getClass().getName().contains("com.sun.management")) {
- // Use reflection to access com.sun.management.OperatingSystemMXBean methods
- // Handle each method call separately to avoid one failure affecting others
- try {
- totalPhysicalMemory =
- (Long) osBean.getClass().getMethod("getTotalPhysicalMemorySize").invoke(osBean);
- } catch (Exception e) {
- // Suppress: fall back to default -1 when restricted
- }
-
- try {
- availablePhysicalMemory =
- (Long) osBean.getClass().getMethod("getFreePhysicalMemorySize").invoke(osBean);
- } catch (Exception e) {
- // Suppress: fall back to default -1 when restricted
- }
-
- try {
- totalSwapSpace =
- (Long) osBean.getClass().getMethod("getTotalSwapSpaceSize").invoke(osBean);
- } catch (Exception e) {
- // Suppress: fall back to default -1 when restricted
- }
-
- try {
- availableSwapSpace =
- (Long) osBean.getClass().getMethod("getFreeSwapSpaceSize").invoke(osBean);
- } catch (Exception e) {
- // Suppress: fall back to default -1 when restricted
- }
- }
- } catch (Exception e) {
- // Suppress overarching reflection access issues
- }
-
- return OperatingSystem.newBuilder()
- .setName(osBean.getName())
- .setVersion(osBean.getVersion())
- .setArchitecture(osBean.getArch())
- .setManufacturer(System.getProperty("os.name"))
- .setAvailableProcessors(osBean.getAvailableProcessors())
- .setSystemUptime(getSystemUptime())
- .setSystemLoadAverage(osBean.getSystemLoadAverage())
- .setTotalPhysicalMemory(totalPhysicalMemory)
- .setAvailablePhysicalMemory(availablePhysicalMemory)
- .setTotalSwapSpace(totalSwapSpace)
- .setAvailableSwapSpace(availableSwapSpace)
- .build();
- }
-
- private long getSystemUptime() {
- try {
- RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
- return runtimeBean.getUptime();
- } catch (Exception e) {
- log.debug("Could not get system uptime", e);
- return -1;
- }
- }
-
- private JavaRuntime collectJavaRuntimeInfo() {
- RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean();
-
- JavaRuntime.Builder builder =
- JavaRuntime.newBuilder()
- .setVersion(System.getProperty("java.version", ""))
- .setVendor(System.getProperty("java.vendor", ""))
- .setRuntimeName(System.getProperty("java.runtime.name", ""))
- .setVmName(System.getProperty("java.vm.name", ""))
- .setVmVersion(System.getProperty("java.vm.version", ""))
- .setVmVendor(System.getProperty("java.vm.vendor", ""))
- .setSpecificationName(System.getProperty("java.specification.name", ""))
- .setSpecificationVersion(System.getProperty("java.specification.version", ""))
- .setClassPath(System.getProperty("java.class.path", ""))
- .setLibraryPath(System.getProperty("java.library.path", ""))
- .setUptime(runtimeBean.getUptime())
- .setStartTime(runtimeBean.getStartTime())
- .setManagementSpecVersion(runtimeBean.getManagementSpecVersion());
-
- runtimeBean.getInputArguments().forEach(builder::addInputArguments);
-
- return builder.build();
- }
-
- private Hardware collectHardwareInfo() {
- String model = System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "Unknown";
- String manufacturer = "Unknown";
- String serialNumber = "Unknown";
- String uuid = "Unknown";
-
- // Try to get hardware info from system files (Linux)
- try {
- if (System.getProperty("os.name").toLowerCase().contains("linux")) {
- model = readFileContent("/sys/devices/virtual/dmi/id/product_name", model);
- manufacturer = readFileContent("/sys/devices/virtual/dmi/id/sys_vendor", manufacturer);
- serialNumber = readFileContent("/sys/devices/virtual/dmi/id/product_serial", serialNumber);
- uuid = readFileContent("/sys/devices/virtual/dmi/id/product_uuid", uuid);
- }
- } catch (Exception e) {
- // Suppress diagnostic noise when hardware sysfs is inaccessible in containers
- }
-
- return Hardware.newBuilder()
- .setModel(model)
- .setManufacturer(manufacturer)
- .setSerialNumber(serialNumber)
- .setUuid(uuid)
- .setProcessor(collectProcessorInfo())
- .build();
- }
-
- private Processor collectProcessorInfo() {
- OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
-
- String processorName = System.getenv("PROCESSOR_IDENTIFIER");
- if (processorName == null) {
- processorName = readFileContent("/proc/cpuinfo", "Unknown");
- if (!"Unknown".equals(processorName)) {
- processorName =
- Arrays.stream(processorName.split("\n"))
- .filter(line -> line.startsWith("model name"))
- .findFirst()
- .map(line -> line.substring(line.indexOf(':') + 1).trim())
- .orElse("Unknown");
- }
- }
-
- return Processor.newBuilder()
- .setName(processorName)
- .setIdentifier(System.getProperty("java.vm.name", ""))
- .setArchitecture(System.getProperty("os.arch", ""))
- .setPhysicalProcessorCount(osBean.getAvailableProcessors())
- .setLogicalProcessorCount(Runtime.getRuntime().availableProcessors())
- .setMaxFrequency(-1L)
- .setCpu64Bit(
- System.getProperty("os.arch", "")
- .contains("64")) // Changed from setCpu64bit to setCpu64Bit
- .build();
- }
-
- private Memory collectMemoryInfo() {
- MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
- MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
- MemoryUsage nonHeapUsage = memoryBean.getNonHeapMemoryUsage();
-
- Memory.Builder builder =
- Memory.newBuilder()
- .setHeapMemoryUsed(heapUsage.getUsed())
- .setHeapMemoryMax(heapUsage.getMax())
- .setHeapMemoryCommitted(heapUsage.getCommitted())
- .setNonHeapMemoryUsed(nonHeapUsage.getUsed())
- .setNonHeapMemoryMax(nonHeapUsage.getMax())
- .setNonHeapMemoryCommitted(nonHeapUsage.getCommitted());
-
- ManagementFactory.getMemoryPoolMXBeans()
- .forEach(
- pool -> {
- MemoryUsage usage = pool.getUsage();
- builder.addMemoryPools(
- MemoryPool.newBuilder()
- .setName(pool.getName())
- .setType(pool.getType().toString())
- .setUsed(usage.getUsed())
- .setMax(usage.getMax())
- .setCommitted(usage.getCommitted())
- .build());
- });
-
- return builder.build();
- }
-
- private List collectStorageInfo() {
- List storageList = new ArrayList<>();
-
- File[] roots = File.listRoots();
- for (File root : roots) {
- storageList.add(
- Storage.newBuilder()
- .setName(root.getAbsolutePath())
- .setType("disk")
- .setModel("Unknown")
- .setSerialNumber("Unknown")
- .setSize(root.getTotalSpace())
- .build());
- }
-
- return storageList;
- }
-
- private Container collectContainerInfo() {
- boolean isContainer = detectContainer();
- String containerType = detectContainerType();
- String containerId = getContainerId();
- String containerName = System.getenv("HOSTNAME");
- String imageName = getImageName();
-
- Container.Builder builder =
- Container.newBuilder()
- .setIsContainer(isContainer)
- .setContainerType(containerType)
- .setContainerId(containerId)
- .setContainerName(containerName != null ? containerName : "")
- .setImageName(imageName);
-
- Map resourceLimits = new HashMap<>();
- Long memoryLimit =
- parseMemoryLimit(readFileContent("/sys/fs/cgroup/memory/memory.limit_in_bytes", null));
- if (memoryLimit != null) {
- resourceLimits.put("memory_limit", String.valueOf(memoryLimit));
- }
-
- Long cpuLimit = parseCpuLimit(readFileContent("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", null));
- if (cpuLimit != null) {
- resourceLimits.put("cpu_limit", String.valueOf(cpuLimit));
- }
-
- String cpuRequest = System.getenv("CPU_REQUEST");
- if (cpuRequest != null) {
- resourceLimits.put("cpu_request", cpuRequest);
- }
-
- String memoryRequest = System.getenv("MEMORY_REQUEST");
- if (memoryRequest != null) {
- resourceLimits.put("memory_request", memoryRequest);
- }
-
- builder.putAllResourceLimits(resourceLimits);
-
- return builder.build();
- }
-
- private boolean detectContainer() {
- if (Files.exists(Paths.get("/.dockerenv"))) {
- return true;
- }
-
- String cgroupContent = readFileContent("/proc/1/cgroup", "");
- return DOCKER_PATTERN.matcher(cgroupContent).find()
- || KUBERNETES_PATTERN.matcher(cgroupContent).find();
- }
-
- private String detectContainerType() {
- if (System.getenv("KUBERNETES_SERVICE_HOST") != null) {
- return "kubernetes";
- }
- if (Files.exists(Paths.get("/.dockerenv"))) {
- return "docker";
- }
-
- String cgroupContent = readFileContent("/proc/1/cgroup", "");
- if (KUBERNETES_PATTERN.matcher(cgroupContent).find()) {
- return "kubernetes";
- }
- if (DOCKER_PATTERN.matcher(cgroupContent).find()) {
- return "docker";
- }
-
- return "unknown";
- }
-
- private String getContainerId() {
- String cgroupContent = readFileContent("/proc/self/cgroup", "");
- if (!cgroupContent.isEmpty()) {
- String[] lines = cgroupContent.split("\n");
- for (String line : lines) {
- if (line.contains("docker") || line.contains("containerd")) {
- String[] parts = line.split("/");
- if (parts.length > 0) {
- String lastPart = parts[parts.length - 1];
- if (lastPart.length() >= 12) {
- return lastPart.substring(0, 12);
- }
- }
- }
- }
- }
- return System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "";
- }
-
- private String getImageName() {
- String[] imageEnvVars = {
- "IMAGE_NAME", "DOCKER_IMAGE", "CONTAINER_IMAGE", "POD_CONTAINER_IMAGE"
- };
-
- for (String envVar : imageEnvVars) {
- String value = System.getenv(envVar);
- if (value != null && !value.isEmpty()) {
- return value;
- }
- }
-
- return "unknown";
- }
-
- private Map collectKubernetesLabels() {
- Map labels = new HashMap<>();
- System.getenv().entrySet().stream()
- .filter(entry -> entry.getKey().startsWith("LABEL_"))
- .forEach(entry -> labels.put(entry.getKey().substring(6).toLowerCase(), entry.getValue()));
- return labels;
- }
-
- private Map collectKubernetesAnnotations() {
- Map annotations = new HashMap<>();
- System.getenv().entrySet().stream()
- .filter(entry -> entry.getKey().startsWith("ANNOTATION_"))
- .forEach(
- entry -> annotations.put(entry.getKey().substring(11).toLowerCase(), entry.getValue()));
- return annotations;
- }
-
- private Long parseMemoryLimit(String content) {
- if (content == null || content.trim().isEmpty()) {
- return null;
- }
- try {
- long limit = Long.parseLong(content.trim());
- return limit > (1024L * 1024L * 1024L * 1024L) ? null : limit;
- } catch (NumberFormatException e) {
- return null;
- }
- }
-
- private Long parseCpuLimit(String content) {
- if (content == null || content.trim().isEmpty()) {
- return null;
- }
- try {
- return Long.parseLong(content.trim());
- } catch (NumberFormatException e) {
- return null;
- }
- }
-
- private EnvironmentVariables collectEnvironmentVariables() {
- Set sensitiveKeys =
- Set.of(
- "PASSWORD",
- "SECRET",
- "TOKEN",
- "KEY",
- "CREDENTIAL",
- "API_KEY",
- "ACCESS_TOKEN",
- "PRIVATE_KEY");
-
- Map env = System.getenv();
-
- return EnvironmentVariables.newBuilder()
- .setLanguage(env.getOrDefault("LANGUAGE", ""))
- .setPath(env.getOrDefault("PATH", ""))
- .setHostname(env.getOrDefault("HOSTNAME", ""))
- .setLcAll(env.getOrDefault("LC_ALL", ""))
- .setJavaHome(env.getOrDefault("JAVA_HOME", ""))
- .setJavaVersion(env.getOrDefault("JAVA_VERSION", ""))
- .setLang(env.getOrDefault("LANG", ""))
- .setHome(env.getOrDefault("HOME", ""))
- .build();
- }
-
- private SystemProperties collectSystemProperties() {
- Properties props = System.getProperties();
-
- return SystemProperties.newBuilder()
- // Java Specification
- .setJavaSpecificationVersion(props.getProperty("java.specification.version", ""))
- .setJavaSpecificationVendor(props.getProperty("java.specification.vendor", ""))
- .setJavaSpecificationName(props.getProperty("java.specification.name", ""))
- .setJavaSpecificationMaintenanceVersion(
- props.getProperty("java.specification.maintenance.version", ""))
-
- // Java Version Info
- .setJavaVersion(props.getProperty("java.version", ""))
- .setJavaVersionDate(props.getProperty("java.version.date", ""))
- .setJavaVendor(props.getProperty("java.vendor", ""))
- .setJavaVendorVersion(props.getProperty("java.vendor.version", ""))
- .setJavaVendorUrl(props.getProperty("java.vendor.url", ""))
- .setJavaVendorUrlBug(props.getProperty("java.vendor.url.bug", ""))
-
- // Java Runtime
- .setJavaRuntimeName(props.getProperty("java.runtime.name", ""))
- .setJavaRuntimeVersion(props.getProperty("java.runtime.version", ""))
- .setJavaHome(props.getProperty("java.home", ""))
- .setJavaClassPath(props.getProperty("java.class.path", ""))
- .setJavaLibraryPath(props.getProperty("java.library.path", ""))
- .setJavaClassVersion(props.getProperty("java.class.version", ""))
-
- // Java VM
- .setJavaVmName(props.getProperty("java.vm.name", ""))
- .setJavaVmVersion(props.getProperty("java.vm.version", ""))
- .setJavaVmVendor(props.getProperty("java.vm.vendor", ""))
- .setJavaVmInfo(props.getProperty("java.vm.info", ""))
- .setJavaVmSpecificationVersion(props.getProperty("java.vm.specification.version", ""))
- .setJavaVmSpecificationVendor(props.getProperty("java.vm.specification.vendor", ""))
- .setJavaVmSpecificationName(props.getProperty("java.vm.specification.name", ""))
- .setJavaVmCompressedOopsMode(props.getProperty("java.vm.compressedOopsMode", ""))
-
- // Operating System
- .setOsName(props.getProperty("os.name", ""))
- .setOsVersion(props.getProperty("os.version", ""))
- .setOsArch(props.getProperty("os.arch", ""))
-
- // User Info
- .setUserName(props.getProperty("user.name", ""))
- .setUserHome(props.getProperty("user.home", ""))
- .setUserDir(props.getProperty("user.dir", ""))
- .setUserTimezone(props.getProperty("user.timezone", ""))
- .setUserCountry(props.getProperty("user.country", ""))
- .setUserLanguage(props.getProperty("user.language", ""))
-
- // File System
- .setFileSeparator(props.getProperty("file.separator", ""))
- .setPathSeparator(props.getProperty("path.separator", ""))
- .setLineSeparator(props.getProperty("line.separator", ""))
- .setFileEncoding(props.getProperty("file.encoding", ""))
- .setNativeEncoding(props.getProperty("native.encoding", ""))
-
- // Sun/Oracle Specific
- .setSunJnuEncoding(props.getProperty("sun.jnu.encoding", ""))
- .setSunArchDataModel(props.getProperty("sun.arch.data.model", ""))
- .setSunJavaLauncher(props.getProperty("sun.java.launcher", ""))
- .setSunBootLibraryPath(props.getProperty("sun.boot.library.path", ""))
- .setSunJavaCommand(props.getProperty("sun.java.command", ""))
- .setSunCpuEndian(props.getProperty("sun.cpu.endian", ""))
- .setSunManagementCompiler(props.getProperty("sun.management.compiler", ""))
- .setSunIoUnicodeEncoding(props.getProperty("sun.io.unicode.encoding", ""))
-
- // JDK/Debug
- .setJdkDebug(props.getProperty("jdk.debug", ""))
- .setJavaIoTmpdir(props.getProperty("java.io.tmpdir", ""))
-
- // Application Specific
- .setEnv(props.getProperty("env", ""))
- .setMicronautClassloaderLogging(props.getProperty("micronaut.classloader.logging", ""))
-
- // Third Party Libraries
- .setIoNettyAllocatorMaxOrder(props.getProperty("io.netty.allocator.maxOrder", ""))
- .setIoNettyProcessId(props.getProperty("io.netty.processId", ""))
- .setIoNettyMachineId(props.getProperty("io.netty.machineId", ""))
- .setComZaxxerHikariPoolNumber(props.getProperty("com.zaxxer.hikari.pool_number", ""))
- .build();
- }
-
- private String readFileContent(String filePath, String defaultValue) {
- try {
- Path path = Paths.get(filePath);
- if (Files.exists(path)) {
- return Files.readString(path).trim();
- }
- } catch (IOException e) {
- // Intentionally suppress logs for unreadable or restricted system files
- }
- return defaultValue;
- }
-
- public com.tcn.exile.models.DiagnosticsResult collectSerdeableDiagnostics() {
- return com.tcn.exile.models.DiagnosticsResult.fromProto(collectSystemDiagnostics());
- }
-
- /**
- * Common method to collect logs from MemoryAppender using reflection. This eliminates code
- * duplication between different collection methods.
- *
- * @param startTimeMs Start time in milliseconds since epoch, or null for no start limit
- * @param endTimeMs End time in milliseconds since epoch, or null for no end limit
- * @return LogCollectionResult containing logs and success status
- */
- private LogCollectionResult collectLogsFromMemoryAppender(Long startTimeMs, Long endTimeMs) {
- try {
- // Use reflection to access MemoryAppenderInstance.getInstance()
- Class> memoryAppenderInstanceClass =
- Class.forName("com.tcn.exile.memlogger.MemoryAppenderInstance");
- Method getInstanceMethod = memoryAppenderInstanceClass.getMethod("getInstance");
- Object memoryAppenderInstance = getInstanceMethod.invoke(null);
-
- if (memoryAppenderInstance == null) {
- log.warn("MemoryAppender instance is null - no in-memory logs available");
- return LogCollectionResult.success(new ArrayList<>());
- }
-
- List logs;
-
- // Check if time filtering is requested
- if (startTimeMs != null && endTimeMs != null) {
- // Use reflection to call getEventsInTimeRange() on the MemoryAppender instance
- Method getEventsInTimeRangeMethod =
- memoryAppenderInstance
- .getClass()
- .getMethod("getEventsInTimeRange", long.class, long.class);
- @SuppressWarnings("unchecked")
- List retrievedLogs =
- (List)
- getEventsInTimeRangeMethod.invoke(memoryAppenderInstance, startTimeMs, endTimeMs);
-
- logs = retrievedLogs != null ? retrievedLogs : new ArrayList<>();
- log.debug(
- "Retrieved {} logs within time range {} to {}", logs.size(), startTimeMs, endTimeMs);
- } else {
- // Use reflection to call getEventsAsList() on the MemoryAppender instance
- Method getEventsAsListMethod =
- memoryAppenderInstance.getClass().getMethod("getEventsAsList");
- @SuppressWarnings("unchecked")
- List retrievedLogs =
- (List) getEventsAsListMethod.invoke(memoryAppenderInstance);
-
- logs = retrievedLogs != null ? retrievedLogs : new ArrayList<>();
- log.debug("Retrieved {} logs (no time range specified)", logs.size());
- }
-
- return LogCollectionResult.success(logs);
-
- } catch (ClassNotFoundException e) {
- String errorMsg =
- "MemoryAppender classes not found - memory logging not available: " + e.getMessage();
- log.warn(errorMsg);
- return LogCollectionResult.failure(errorMsg);
- } catch (Exception e) {
- String errorMsg =
- "Failed to retrieve logs from memory appender using reflection: " + e.getMessage();
- log.warn(errorMsg);
- return LogCollectionResult.failure(errorMsg);
- }
- }
-
- /** Helper method to convert protobuf Timestamp to milliseconds since epoch. */
- private long convertTimestampToMs(com.google.protobuf.Timestamp timestamp) {
- return timestamp.getSeconds() * 1000 + timestamp.getNanos() / 1000000;
- }
-
- /** Helper method to create a protobuf Timestamp from milliseconds since epoch. */
- private com.google.protobuf.Timestamp createTimestamp(long timeMs) {
- return com.google.protobuf.Timestamp.newBuilder()
- .setSeconds(timeMs / 1000)
- .setNanos((int) ((timeMs % 1000) * 1000000))
- .build();
- }
-
- /**
- * Collects metrics for all HikariCP connection pools in the application.
- *
- * @return List of HikariPoolMetrics objects with data about each connection pool
- */
- private List collectHikariMetrics() {
- log.info("Collecting HikariCP connection pool metrics...");
- List poolMetrics = new ArrayList<>();
-
- try {
- // Get the platform MBean server
- MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
-
- // Search for all HikariCP pools
- Set hikariPoolNames =
- mBeanServer.queryNames(new ObjectName("com.zaxxer.hikari:type=Pool *"), null);
-
- if (hikariPoolNames.isEmpty()) {
- log.info("No HikariCP connection pools found.");
- return poolMetrics;
- }
-
- log.info("Found {} HikariCP connection pool(s)", hikariPoolNames.size());
-
- // For each pool, collect metrics
- for (ObjectName poolName : hikariPoolNames) {
- String poolNameStr = poolName.getKeyProperty("type").split(" ")[1];
- if (poolNameStr.startsWith("(") && poolNameStr.endsWith(")")) {
- poolNameStr = poolNameStr.substring(1, poolNameStr.length() - 1);
- }
-
- try {
- // Basic metrics
- int activeConnections = (Integer) mBeanServer.getAttribute(poolName, "ActiveConnections");
- int idleConnections = (Integer) mBeanServer.getAttribute(poolName, "IdleConnections");
- int totalConnections = (Integer) mBeanServer.getAttribute(poolName, "TotalConnections");
- int threadsAwaitingConnection =
- (Integer) mBeanServer.getAttribute(poolName, "ThreadsAwaitingConnection");
-
- // Create builder for this pool
- HikariPoolMetrics.Builder poolMetricBuilder =
- HikariPoolMetrics.newBuilder()
- .setPoolName(poolNameStr)
- .setActiveConnections(activeConnections)
- .setIdleConnections(idleConnections)
- .setTotalConnections(totalConnections)
- .setThreadsAwaitingConnection(threadsAwaitingConnection);
-
- // Try to get configuration metrics
- try {
- ObjectName configName =
- new ObjectName("com.zaxxer.hikari:type=PoolConfig (" + poolNameStr + ")");
- if (mBeanServer.isRegistered(configName)) {
- long connectionTimeout =
- (Long) mBeanServer.getAttribute(configName, "ConnectionTimeout");
- long validationTimeout =
- (Long) mBeanServer.getAttribute(configName, "ValidationTimeout");
- long idleTimeout = (Long) mBeanServer.getAttribute(configName, "IdleTimeout");
- long maxLifetime = (Long) mBeanServer.getAttribute(configName, "MaxLifetime");
- int minimumIdle = (Integer) mBeanServer.getAttribute(configName, "MinimumIdle");
- int maximumPoolSize =
- (Integer) mBeanServer.getAttribute(configName, "MaximumPoolSize");
- long leakDetectionThreshold =
- (Long) mBeanServer.getAttribute(configName, "LeakDetectionThreshold");
- String poolName_ = (String) mBeanServer.getAttribute(configName, "PoolName");
-
- // Build pool config
- HikariPoolMetrics.PoolConfig.Builder configBuilder =
- HikariPoolMetrics.PoolConfig.newBuilder()
- .setPoolName(poolName_)
- .setConnectionTimeout(connectionTimeout)
- .setValidationTimeout(validationTimeout)
- .setIdleTimeout(idleTimeout)
- .setMaxLifetime(maxLifetime)
- .setMinimumIdle(minimumIdle)
- .setMaximumPoolSize(maximumPoolSize)
- .setLeakDetectionThreshold(leakDetectionThreshold);
-
- // Get JDBC URL and username from plugin configuration
- String jdbcUrl = "";
- String username = "";
- try {
- Collection plugins =
- applicationContext.getBeansOfType(PluginInterface.class);
- for (PluginInterface plugin : plugins) {
- var pluginStatus = plugin.getPluginStatus();
- Map internalConfig = pluginStatus.internalConfig();
-
- if (internalConfig != null && !internalConfig.isEmpty()) {
- Object jdbcUrlObj = internalConfig.get("jdbc_url");
- Object jdbcUserObj = internalConfig.get("jdbc_user");
-
- if (jdbcUrlObj != null && jdbcUserObj != null) {
- jdbcUrl = jdbcUrlObj.toString();
- username = jdbcUserObj.toString();
- log.debug(
- "Retrieved JDBC config from plugin: url={}, user={}", jdbcUrl, username);
- break;
- }
- }
- }
- } catch (Exception e) {
- log.warn("Failed to get JDBC config from plugins: {}", e.getMessage());
- }
-
- configBuilder.setJdbcUrl(jdbcUrl).setUsername(username);
-
- poolMetricBuilder.setPoolConfig(configBuilder);
- }
- } catch (Exception e) {
- log.debug("Error accessing pool configuration: {}", e.getMessage());
- }
-
- // Collect extended metrics if available
- Map extendedMetrics = new HashMap<>();
- try {
- Set metricNames =
- mBeanServer.queryNames(
- new ObjectName("metrics:name=hikaricp." + poolNameStr + ".*"), null);
-
- for (ObjectName metricName : metricNames) {
- String metricType = metricName.getKeyProperty("name");
- try {
- Object value = mBeanServer.getAttribute(metricName, "Value");
- extendedMetrics.put(metricType, String.valueOf(value));
- } catch (Exception e) {
- log.debug("Could not get metric value for {}: {}", metricType, e.getMessage());
- }
- }
- } catch (Exception e) {
- log.debug("Error accessing Dropwizard metrics: {}", e.getMessage());
- }
-
- poolMetricBuilder.putAllExtendedMetrics(extendedMetrics);
- poolMetrics.add(poolMetricBuilder.build());
-
- } catch (Exception e) {
- log.error("Error getting metrics for pool {}: {}", poolNameStr, e.getMessage());
- }
- }
- } catch (Exception e) {
- log.error("Error collecting HikariCP metrics", e);
- }
-
- return poolMetrics;
- }
-
- /**
- * Collects configuration details from the config file.
- *
- * @return ConfigDetails object with API endpoint and certificate information
- */
- private ConfigDetails collectConfigDetails() {
- log.info("Collecting configuration file details...");
- ConfigDetails.Builder configBuilder = ConfigDetails.newBuilder();
-
- // Define the config paths and filename
- final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg";
- final List watchList = List.of(Path.of("/workdir/config"), Path.of("workdir/config"));
-
- try {
- // Find the first valid directory
- Optional configDir =
- watchList.stream().filter(path -> path.toFile().exists()).findFirst();
-
- if (configDir.isEmpty()) {
- log.error("No valid config directory found");
- return configBuilder.build();
- }
-
- Path configFile = configDir.get().resolve(CONFIG_FILE_NAME);
- if (!configFile.toFile().exists()) {
- log.error("Config file not found: {}", configFile);
- return configBuilder.build();
- }
-
- log.info("Found config file: {}", configFile);
- byte[] base64EncodedData = Files.readAllBytes(configFile);
- String dataString = new String(base64EncodedData);
-
- // Trim and ensure proper base64 length
- byte[] data = dataString.trim().getBytes();
- byte[] decodedData;
-
- try {
- decodedData = Base64.getDecoder().decode(data);
- } catch (IllegalArgumentException e) {
- // Try removing the last byte which might be a newline
- log.debug("Failed to decode base64, trying with truncated data");
- decodedData = Base64.getDecoder().decode(Arrays.copyOf(data, data.length - 1));
- }
-
- // Convert to string for processing
- String jsonString = new String(decodedData);
-
- // Extract JSON values
- String apiEndpoint = extractJsonValue(jsonString, "api_endpoint");
- String certificateName = extractJsonValue(jsonString, "certificate_name");
- String certificateDescription = extractJsonValue(jsonString, "certificate_description");
-
- // Build Config Details
- configBuilder
- .setApiEndpoint(apiEndpoint)
- .setCertificateName(certificateName)
- .setCertificateDescription(certificateDescription);
-
- } catch (IOException e) {
- log.error("Error reading or parsing config file", e);
- } catch (Exception e) {
- log.error("Unexpected error processing config file", e);
- }
-
- return configBuilder.build();
- }
-
- /**
- * Simple method to extract a JSON value by key from a JSON string. This is a basic implementation
- * that works for first-level string values in JSON.
- */
- private String extractJsonValue(String jsonString, String key) {
- String searchPattern = "\"" + key + "\"\\s*:\\s*\"([^\"]*)\"";
- java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(searchPattern);
- java.util.regex.Matcher matcher = pattern.matcher(jsonString);
-
- if (matcher.find()) {
- return matcher.group(1);
- }
- return "Not found";
- }
-
- /**
- * Get JDBC connection details for diagnostic purposes.
- *
- * @return Map containing JDBC connection information
- */
- public Map getJdbcConnectionDetails() {
- Map jdbcDetails = new HashMap<>();
-
- try {
- MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
- Set hikariPoolNames =
- mBeanServer.queryNames(new ObjectName("com.zaxxer.hikari:type=Pool *"), null);
-
- for (ObjectName poolName : hikariPoolNames) {
- String poolNameStr = poolName.getKeyProperty("type").split(" ")[1];
- if (poolNameStr.startsWith("(") && poolNameStr.endsWith(")")) {
- poolNameStr = poolNameStr.substring(1, poolNameStr.length() - 1);
- }
-
- Map poolInfo = new HashMap<>();
-
- // Get basic connection metrics
- poolInfo.put("activeConnections", mBeanServer.getAttribute(poolName, "ActiveConnections"));
- poolInfo.put("idleConnections", mBeanServer.getAttribute(poolName, "IdleConnections"));
- poolInfo.put("totalConnections", mBeanServer.getAttribute(poolName, "TotalConnections"));
-
- // Get configuration details
- try {
- ObjectName configName =
- new ObjectName("com.zaxxer.hikari:type=PoolConfig (" + poolNameStr + ")");
- if (mBeanServer.isRegistered(configName)) {
- poolInfo.put("jdbcUrl", mBeanServer.getAttribute(configName, "JdbcUrl"));
- poolInfo.put("username", mBeanServer.getAttribute(configName, "Username"));
-
- // Try to get driver class name
- try {
- poolInfo.put(
- "driverClassName", mBeanServer.getAttribute(configName, "DriverClassName"));
- } catch (Exception e) {
- log.debug("Driver class name not available: {}", e.getMessage());
- poolInfo.put("driverClassName", "Unknown");
- }
- }
- } catch (Exception e) {
- log.debug("Error accessing pool configuration: {}", e.getMessage());
- }
-
- jdbcDetails.put(poolNameStr, poolInfo);
- }
- } catch (Exception e) {
- log.error("Error collecting JDBC connection details", e);
- }
-
- return jdbcDetails;
- }
-
- /**
- * Get event stream statistics from Plugin instances directly.
- *
- * @return Map containing event stream stats
- */
- public Map getEventStreamStats() {
- Map eventStreamStats = new HashMap<>();
-
- try {
- // Get all Plugin instances from the application context
- Collection plugins =
- applicationContext.getBeansOfType(PluginInterface.class);
-
- if (plugins.isEmpty()) {
- log.info("No Plugin instances found");
- eventStreamStats.put("status", "Not available");
- return eventStreamStats;
- }
-
- log.info("Found {} plugin instance(s)", plugins.size());
-
- for (PluginInterface plugin : plugins) {
- try {
- var pluginStatus = plugin.getPluginStatus();
- String pluginName = pluginStatus.name(); // PluginStatus is a record
-
- Map streamInfo = new HashMap<>();
- streamInfo.put("status", pluginStatus.running() ? "running" : "stopped");
- streamInfo.put("maxJobs", pluginStatus.queueMaxSize());
- streamInfo.put("runningJobs", pluginStatus.queueActiveCount());
- streamInfo.put("completedJobs", pluginStatus.queueCompletedJobs());
- streamInfo.put("queuedJobs", pluginStatus.queueActiveCount());
-
- eventStreamStats.put(pluginName + "-plugin", streamInfo);
-
- log.debug(
- "Collected stats for plugin {}: running={}, maxJobs={}, activeJobs={}, completedJobs={}",
- pluginName,
- pluginStatus.running(),
- pluginStatus.queueMaxSize(),
- pluginStatus.queueActiveCount(),
- pluginStatus.queueCompletedJobs());
-
- } catch (Exception e) {
- log.error("Error getting stats for plugin: {}", e.getMessage(), e);
- }
- }
-
- } catch (Exception e) {
- log.error("Error collecting plugin-based event stream stats", e);
- eventStreamStats.put("error", e.getMessage());
- }
-
- return eventStreamStats;
- }
-
- /**
- * Collects event stream statistics for the application's event stream.
- *
- * @return EventStreamStats object with aggregated data about the application's event stream
- */
- private EventStreamStats collectEventStreamStats() {
- log.info("Collecting event stream statistics...");
-
- try {
- // Get all Plugin instances from the application context
- Collection plugins =
- applicationContext.getBeansOfType(PluginInterface.class);
-
- if (plugins.isEmpty()) {
- log.info("No Plugin instances found");
- return EventStreamStats.newBuilder()
- .setStreamName("application")
- .setStatus("not available")
- .setMaxJobs(-1)
- .setRunningJobs(-1)
- .setCompletedJobs(-1)
- .setQueuedJobs(-1)
- .build();
- }
-
- log.info("Found {} plugin instance(s)", plugins.size());
-
- // Aggregate stats from all plugins
- String overallStatus = "stopped";
- int totalMaxJobs = 0;
- int totalRunningJobs = 0;
- long totalCompletedJobs = 0;
- int totalQueuedJobs = 0;
- boolean anyPluginRunning = false;
-
- for (PluginInterface plugin : plugins) {
- try {
- var pluginStatus = plugin.getPluginStatus();
-
- if (pluginStatus.running()) {
- anyPluginRunning = true;
- }
-
- totalMaxJobs += pluginStatus.queueMaxSize();
- totalRunningJobs += pluginStatus.queueActiveCount();
- totalCompletedJobs += pluginStatus.queueCompletedJobs();
-
- // Calculate queued jobs as max - active
- int queuedJobs =
- Math.max(0, pluginStatus.queueMaxSize() - pluginStatus.queueActiveCount());
- totalQueuedJobs += queuedJobs;
-
- log.debug(
- "Plugin stats: running={}, maxJobs={}, activeJobs={}, completedJobs={}",
- pluginStatus.running(),
- pluginStatus.queueMaxSize(),
- pluginStatus.queueActiveCount(),
- pluginStatus.queueCompletedJobs());
-
- } catch (Exception e) {
- log.error("Error getting stats for plugin: {}", e.getMessage(), e);
- }
- }
-
- // Determine overall status
- if (anyPluginRunning) {
- overallStatus = "running";
- } else if (totalMaxJobs > 0) {
- overallStatus = "stopped";
- } else {
- overallStatus = "not configured";
- }
-
- // Create aggregated EventStreamStats
- return EventStreamStats.newBuilder()
- .setStreamName("application")
- .setStatus(overallStatus)
- .setMaxJobs(totalMaxJobs)
- .setRunningJobs(totalRunningJobs)
- .setCompletedJobs(totalCompletedJobs)
- .setQueuedJobs(totalQueuedJobs)
- .build();
-
- } catch (Exception e) {
- log.error("Error collecting event stream statistics", e);
- return EventStreamStats.newBuilder()
- .setStreamName("application")
- .setStatus("error: " + e.getMessage())
- .setMaxJobs(-1)
- .setRunningJobs(-1)
- .setCompletedJobs(-1)
- .setQueuedJobs(-1)
- .build();
- }
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java b/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java
deleted file mode 100644
index 1ce2785..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients;
-
-public class UnconfiguredException extends Exception {
- public UnconfiguredException() {
- super();
- }
-
- public UnconfiguredException(String message) {
- super(message);
- }
-
- public UnconfiguredException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public UnconfiguredException(Throwable cause) {
- super(cause);
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java b/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java
deleted file mode 100644
index c69030c..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-public class BuildVersion {
- public static String getBuildVersion() {
- return BuildVersion.class.getPackage().getImplementationVersion();
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java
deleted file mode 100644
index 218c4a7..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java
+++ /dev/null
@@ -1,328 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.*;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.log.LogCategory;
-import com.tcn.exile.log.StructuredLogger;
-import com.tcn.exile.models.OrgInfo;
-import io.grpc.ManagedChannel;
-import io.grpc.StatusRuntimeException;
-import java.util.Iterator;
-import java.util.concurrent.TimeUnit;
-
-public class GateClient extends GateClientAbstract {
- private static final StructuredLogger log = new StructuredLogger(GateClient.class);
- private static final int DEFAULT_TIMEOUT_SECONDS = 30;
-
- public GateClient(String tenant, Config currentConfig) {
- super(tenant, currentConfig);
- }
-
- @Override
- public void start() {
- // this does not need any implementation
- }
-
- protected GateServiceGrpc.GateServiceBlockingStub getStub(ManagedChannel channel) {
- return GateServiceGrpc.newBlockingStub(channel)
- .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
- }
-
- public T executeRequest(String operationName, GrpcOperation grpcOperation) {
- try {
- var result = grpcOperation.execute(getChannel());
- if (result == null) {
- throw new RuntimeException("Received null response from " + operationName);
- }
- return result;
- } catch (UnconfiguredException e) {
- log.error(
- LogCategory.GRPC,
- "OperationFailed",
- "Failed to execute %s operation: %s",
- operationName,
- e.getMessage());
- throw new RuntimeException(e);
- } catch (StatusRuntimeException e) {
- if (handleStatusRuntimeException(e)) {
- log.warn(
- LogCategory.GRPC,
- "ConnectionIssue",
- "Connection issue during %s operation, channel reset: %s",
- operationName,
- e.getMessage());
- throw new RuntimeException(
- "Connection issue during " + operationName + ", please retry", e);
- }
- log.error(
- LogCategory.GRPC,
- "GrpcError",
- "gRPC error during %s operation: %s (%s)",
- operationName,
- e.getMessage(),
- e.getStatus().getCode());
- throw new RuntimeException("Failed to execute " + operationName, e);
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "UnexpectedError",
- "Unexpected error during %s operation: %s",
- operationName,
- e.getMessage());
- throw new RuntimeException("Failed to execute " + operationName, e);
- }
- }
-
- @FunctionalInterface
- private interface GrpcOperation {
- T execute(ManagedChannel channel);
- }
-
- // Organization details retrieval
- public OrgInfo getOrganizationInfo() {
- return executeRequest(
- "getOrganizationInfo",
- client -> {
- var result =
- getStub(client).getOrganizationInfo(GetOrganizationInfoRequest.newBuilder().build());
- return new OrgInfo(result.getOrgName(), result.getOrgName());
- });
- }
-
- // Job results submission (max 2MB)
- public SubmitJobResultsResponse submitJobResults(SubmitJobResultsRequest request) {
- log.info(
- LogCategory.GRPC,
- "SubmitJobResults",
- "GateClient submit job results request: %s",
- request.getJobId());
- try {
- return executeRequest(
- "submitJobResults",
- client -> {
- var response = getStub(client).submitJobResults(request);
- if (response == null) {
- throw new RuntimeException("Received null response from submitJobResults");
- }
- return response;
- });
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "SubmitJobResultsFailed",
- "Failed to submit job results for job %s: %s",
- request.getJobId(),
- e.getMessage());
- throw new RuntimeException("Failed to submit job results", e);
- }
- }
-
- // Agent state management
- public GetAgentStatusResponse getAgentStatus(GetAgentStatusRequest request) {
- return executeRequest("getAgentStatus", client -> getStub(client).getAgentStatus(request));
- }
-
- public UpdateAgentStatusResponse updateAgentStatus(UpdateAgentStatusRequest request) {
- return executeRequest(
- "updateAgentStatus", client -> getStub(client).updateAgentStatus(request));
- }
-
- public Iterator listAgents(ListAgentsRequest request) {
- return executeRequest("listAgents", client -> getStub(client).listAgents(request));
- }
-
- public UpsertAgentResponse upsertAgent(UpsertAgentRequest request) {
- return executeRequest("upsertAgent", client -> getStub(client).upsertAgent(request));
- }
-
- public GetAgentByIdResponse getAgentById(GetAgentByIdRequest request) {
- return executeRequest("getAgentById", client -> getStub(client).getAgentById(request));
- }
-
- public GetAgentByPartnerIdResponse getAgentByPartnerId(GetAgentByPartnerIdRequest request) {
- return executeRequest(
- "getAgentByPartnerId", client -> getStub(client).getAgentByPartnerId(request));
- }
-
- // Telephony operations
- public DialResponse dial(DialRequest request) {
- return executeRequest("dial", client -> getStub(client).dial(request));
- }
-
- public ListNCLRulesetNamesResponse listNCLRulesetNames(ListNCLRulesetNamesRequest request) {
- return executeRequest(
- "listNCLRulesetNames", client -> getStub(client).listNCLRulesetNames(request));
- }
-
- // Recording controls
- public StartCallRecordingResponse startCallRecording(StartCallRecordingRequest request) {
- return executeRequest(
- "startCallRecording", client -> getStub(client).startCallRecording(request));
- }
-
- public StopCallRecordingResponse stopCallRecording(StopCallRecordingRequest request) {
- return executeRequest(
- "stopCallRecording", client -> getStub(client).stopCallRecording(request));
- }
-
- public GetRecordingStatusResponse getRecordingStatus(GetRecordingStatusRequest request) {
- return executeRequest(
- "getRecordingStatus", client -> getStub(client).getRecordingStatus(request));
- }
-
- // Scrub list management
- public ListScrubListsResponse listScrubLists(ListScrubListsRequest request) {
- return executeRequest("listScrubLists", client -> getStub(client).listScrubLists(request));
- }
-
- public AddScrubListEntriesResponse addScrubListEntries(AddScrubListEntriesRequest request) {
- return executeRequest(
- "addScrubListEntries", client -> getStub(client).addScrubListEntries(request));
- }
-
- public UpdateScrubListEntryResponse updateScrubListEntry(UpdateScrubListEntryRequest request) {
- return executeRequest(
- "updateScrubListEntry", client -> getStub(client).updateScrubListEntry(request));
- }
-
- public RemoveScrubListEntriesResponse removeScrubListEntries(
- RemoveScrubListEntriesRequest request) {
- return executeRequest(
- "removeScrubListEntries", client -> getStub(client).removeScrubListEntries(request));
- }
-
- public LogResponse log(LogRequest request) {
- return executeRequest("log", client -> getStub(client).log(request));
- }
-
- public AddAgentCallResponseResponse addAgentCallResponse(AddAgentCallResponseRequest request) {
- return executeRequest(
- "addAgentCallResponse", client -> getStub(client).addAgentCallResponse(request));
- }
-
- public ListHuntGroupPauseCodesResponse listHuntGroupPauseCodes(
- ListHuntGroupPauseCodesRequest request) {
- return executeRequest(
- "listHuntGroupPauseCodes", client -> getStub(client).listHuntGroupPauseCodes(request));
- }
-
- public PutCallOnSimpleHoldResponse putCallOnSimpleHold(PutCallOnSimpleHoldRequest request) {
- return executeRequest(
- "putCallOnSimpleHold", client -> getStub(client).putCallOnSimpleHold(request));
- }
-
- public TakeCallOffSimpleHoldResponse takeCallOffSimpleHold(TakeCallOffSimpleHoldRequest request) {
- return executeRequest(
- "takeCallOffSimpleHold", client -> getStub(client).takeCallOffSimpleHold(request));
- }
-
- public HoldTransferMemberCallerResponse holdTransferMemberCaller(
- HoldTransferMemberCallerRequest request) {
- return executeRequest(
- "holdTransferMemberCaller", client -> getStub(client).holdTransferMemberCaller(request));
- }
-
- public UnholdTransferMemberCallerResponse unholdTransferMemberCaller(
- UnholdTransferMemberCallerRequest request) {
- return executeRequest(
- "unholdTransferMemberCaller",
- client -> getStub(client).unholdTransferMemberCaller(request));
- }
-
- public HoldTransferMemberAgentResponse holdTransferMemberAgent(
- HoldTransferMemberAgentRequest request) {
- return executeRequest(
- "holdTransferMemberAgent", client -> getStub(client).holdTransferMemberAgent(request));
- }
-
- public UnholdTransferMemberAgentResponse unholdTransferMemberAgent(
- UnholdTransferMemberAgentRequest request) {
- return executeRequest(
- "unholdTransferMemberAgent", client -> getStub(client).unholdTransferMemberAgent(request));
- }
-
- public MuteAgentResponse muteAgent(MuteAgentRequest request) {
- return executeRequest("muteAgent", client -> getStub(client).muteAgent(request));
- }
-
- public UnmuteAgentResponse unmuteAgent(UnmuteAgentRequest request) {
- return executeRequest("unmuteAgent", client -> getStub(client).unmuteAgent(request));
- }
-
- public RotateCertificateResponse rotateCertificate(RotateCertificateRequest request) {
- return executeRequest(
- "rotateCertificate", client -> getStub(client).rotateCertificate(request));
- }
-
- public Iterator searchVoiceRecordings(
- SearchVoiceRecordingsRequest request) {
- return executeRequest(
- "searchVoiceRecordings", client -> getStub(client).searchVoiceRecordings(request));
- }
-
- public GetVoiceRecordingDownloadLinkResponse getVoiceRecordingDownloadLink(
- GetVoiceRecordingDownloadLinkRequest request) {
- return executeRequest(
- "getVoiceRecordingDownloadLink",
- client -> getStub(client).getVoiceRecordingDownloadLink(request));
- }
-
- public ListSearchableRecordingFieldsResponse listSearchableRecordingFields(
- ListSearchableRecordingFieldsRequest request) {
- return executeRequest(
- "listSearchableRecordingFields",
- client -> getStub(client).listSearchableRecordingFields(request));
- }
-
- public CreateRecordingLabelResponse createRecordingLabel(CreateRecordingLabelRequest request) {
- return executeRequest(
- "createRecordingLabel", client -> getStub(client).createRecordingLabel(request));
- }
-
- public ListSkillsResponse ListSkills(ListSkillsRequest request) {
- return executeRequest("listSkills", client -> getStub(client).listSkills(request));
- }
-
- // List all skills assigned to an agent, and their proficiency
- public ListAgentSkillsResponse ListAgentSkills(ListAgentSkillsRequest request) {
- return executeRequest("listAgentSkills", client -> getStub(client).listAgentSkills(request));
- }
-
- // Assign a skill to an agent
- public AssignAgentSkillResponse AssignAgentSkill(AssignAgentSkillRequest request) {
- return executeRequest("assignAgentSkill", client -> getStub(client).assignAgentSkill(request));
- }
-
- // Unassign a skill from an agent
- public UnassignAgentSkillResponse UnassignAgentSkill(UnassignAgentSkillRequest request) {
- return executeRequest(
- "unassignAgentSkill", client -> getStub(client).unassignAgentSkill(request));
- }
-
- public TransferResponse transfer(TransferRequest request) {
- return executeRequest("transfer", client -> getStub(client).transfer(request));
- }
-
- public AddRecordToJourneyBufferResponse addRecordToJourneyBuffer(
- AddRecordToJourneyBufferRequest request) {
- return executeRequest(
- "addRecordToJourneyBuffer", client -> getStub(client).addRecordToJourneyBuffer(request));
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java
deleted file mode 100644
index 36dc224..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java
+++ /dev/null
@@ -1,578 +0,0 @@
-/*
- * (C) 2017-2026 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import io.grpc.Grpc;
-import io.grpc.ManagedChannel;
-import io.grpc.Status;
-import io.grpc.StatusRuntimeException;
-import io.grpc.TlsChannelCredentials;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ThreadLocalRandom;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.concurrent.locks.ReentrantLock;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * Base class for bidirectional gRPC stream clients.
- *
- * Provides shared infrastructure for stream lifecycle management including exponential backoff
- * with jitter, hung connection detection, connection tracking, and graceful shutdown. Subclasses
- * implement {@link #runStream()} for stream-specific logic and {@link #getStreamName()} for
- * logging.
- */
-public abstract class GateClientAbstract {
- private static final Logger log = LoggerFactory.getLogger(GateClientAbstract.class);
-
- // Stream lifecycle constants
- protected static final long STREAM_TIMEOUT_MINUTES = 5;
- protected static final long HUNG_CONNECTION_THRESHOLD_SECONDS = 45;
-
- // Backoff configuration
- private static final long BACKOFF_BASE_MS = 2000;
- private static final long BACKOFF_MAX_MS = 30000;
- private static final double BACKOFF_JITTER = 0.2;
-
- // Channel management
- private ManagedChannel sharedChannel;
- private final ReentrantLock lock = new ReentrantLock();
-
- private Config currentConfig = null;
- protected final String tenant;
-
- // Connection tracking — shared across all stream subclasses
- protected final AtomicReference lastMessageTime = new AtomicReference<>();
- protected final AtomicReference lastDisconnectTime = new AtomicReference<>();
- protected final AtomicReference reconnectionStartTime = new AtomicReference<>();
- protected final AtomicReference connectionEstablishedTime = new AtomicReference<>();
- protected final AtomicLong totalReconnectionAttempts = new AtomicLong(0);
- protected final AtomicLong successfulReconnections = new AtomicLong(0);
- protected final AtomicReference lastErrorType = new AtomicReference<>();
- protected final AtomicLong consecutiveFailures = new AtomicLong(0);
- protected final AtomicBoolean isRunning = new AtomicBoolean(false);
-
- public GateClientAbstract(String tenant, Config currentConfig) {
- this.currentConfig = currentConfig;
- this.tenant = tenant;
-
- Runtime.getRuntime()
- .addShutdownHook(
- new Thread(
- () -> {
- log.info("Shutdown hook triggered - cleaning up gRPC channels");
- forceShutdownSharedChannel();
- },
- "gRPC-Channel-Cleanup"));
- }
-
- // ---------------------------------------------------------------------------
- // Stream lifecycle — template method pattern
- // ---------------------------------------------------------------------------
-
- /**
- * Human-readable name for this stream, used in log messages. Override in subclasses that use the
- * template {@link #start()} method (e.g. "EventStream", "JobQueue").
- */
- protected String getStreamName() {
- return getClass().getSimpleName();
- }
-
- /**
- * Run a single stream session. Called by the template {@link #start()} method. Subclasses that
- * use the shared lifecycle must override this. Legacy subclasses that override start() directly
- * can ignore it.
- */
- protected void runStream()
- throws UnconfiguredException, InterruptedException, HungConnectionException {
- throw new UnsupportedOperationException(
- getStreamName() + " must override runStream() or start()");
- }
-
- /**
- * Template method that handles the full stream lifecycle: backoff, try/catch, error tracking.
- * Each invocation represents one connection attempt.
- */
- public void start() {
- if (isUnconfigured()) {
- log.warn("{} is unconfigured, cannot start", getStreamName());
- return;
- }
-
- // Prevent concurrent invocations from the fixed-rate scheduler
- if (!isRunning.compareAndSet(false, true)) {
- return;
- }
-
- // Exponential backoff: sleep before retrying if we have consecutive failures
- long backoffMs = computeBackoffMs();
- if (backoffMs > 0) {
- log.debug(
- "[{}] Waiting {}ms before reconnect (consecutive failures: {})",
- getStreamName(),
- backoffMs,
- consecutiveFailures.get());
- try {
- Thread.sleep(backoffMs);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- isRunning.set(false);
- return;
- }
- }
-
- try {
- log.debug("[{}] Starting, checking configuration status", getStreamName());
- reconnectionStartTime.set(Instant.now());
- resetChannelBackoff();
- runStream();
- } catch (HungConnectionException e) {
- log.warn("[{}] Connection appears hung: {}", getStreamName(), e.getMessage());
- lastErrorType.set("HungConnection");
- consecutiveFailures.incrementAndGet();
- } catch (UnconfiguredException e) {
- log.error("[{}] Configuration error: {}", getStreamName(), e.getMessage());
- lastErrorType.set("UnconfiguredException");
- consecutiveFailures.incrementAndGet();
- } catch (InterruptedException e) {
- log.info("[{}] Interrupted", getStreamName());
- Thread.currentThread().interrupt();
- } catch (Exception e) {
- if (isRoutineStreamClosure(e)) {
- log.debug("[{}] Stream closed by server (routine reconnect)", getStreamName());
- } else {
- log.error("[{}] Error: {}", getStreamName(), e.getMessage(), e);
- }
- lastErrorType.set(e.getClass().getSimpleName());
- consecutiveFailures.incrementAndGet();
- } finally {
- totalReconnectionAttempts.incrementAndGet();
- lastDisconnectTime.set(Instant.now());
- isRunning.set(false);
- onStreamDisconnected();
- log.debug("[{}] Stream session ended", getStreamName());
- }
- }
-
- /** Hook called in the finally block of start(). Subclasses can clear observer refs here. */
- protected void onStreamDisconnected() {}
-
- // ---------------------------------------------------------------------------
- // Shared stream helpers
- // ---------------------------------------------------------------------------
-
- /** Record that the first server response was received and the connection is established. */
- protected void onConnectionEstablished() {
- connectionEstablishedTime.set(Instant.now());
- successfulReconnections.incrementAndGet();
- consecutiveFailures.set(0);
- lastErrorType.set(null);
-
- log.info(
- "[{}] Connection established (took {})",
- getStreamName(),
- Duration.between(reconnectionStartTime.get(), connectionEstablishedTime.get()));
- }
-
- /** Check if the connection is hung (no messages received within threshold). */
- protected void checkForHungConnection() throws HungConnectionException {
- Instant lastMsg = lastMessageTime.get();
- if (lastMsg == null) {
- lastMsg = connectionEstablishedTime.get();
- }
- if (lastMsg != null
- && lastMsg.isBefore(
- Instant.now().minus(HUNG_CONNECTION_THRESHOLD_SECONDS, ChronoUnit.SECONDS))) {
- throw new HungConnectionException(
- "No messages received since " + lastMsg + " - connection appears hung");
- }
- }
-
- /**
- * Wait for a stream latch to complete, periodically checking for hung connections. Returns when
- * the latch counts down, the timeout expires, or a hung connection is detected.
- */
- protected void awaitStreamWithHungDetection(CountDownLatch latch)
- throws InterruptedException, HungConnectionException {
- long startTime = System.currentTimeMillis();
- long maxDurationMs = TimeUnit.MINUTES.toMillis(STREAM_TIMEOUT_MINUTES);
-
- while ((System.currentTimeMillis() - startTime) < maxDurationMs) {
- if (latch.await(HUNG_CONNECTION_THRESHOLD_SECONDS, TimeUnit.SECONDS)) {
- return; // Stream completed
- }
- checkForHungConnection();
- }
- }
-
- /** Compute backoff delay with jitter based on consecutive failure count. */
- private long computeBackoffMs() {
- long failures = consecutiveFailures.get();
- if (failures <= 0) {
- return 0;
- }
- long delayMs = BACKOFF_BASE_MS * (1L << Math.min(failures - 1, 10));
- double jitter = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * BACKOFF_JITTER;
- return Math.min((long) (delayMs * jitter), BACKOFF_MAX_MS);
- }
-
- /**
- * Build base stream status map with connection tracking fields. Subclasses should call this and
- * add their own stream-specific counters.
- */
- protected Map buildStreamStatus() {
- Instant lastDisconnect = lastDisconnectTime.get();
- Instant connectionEstablished = connectionEstablishedTime.get();
- Instant reconnectStart = reconnectionStartTime.get();
- Instant lastMessage = lastMessageTime.get();
-
- Map status = new HashMap<>();
- status.put("isRunning", isRunning.get());
- status.put("totalReconnectionAttempts", totalReconnectionAttempts.get());
- status.put("successfulReconnections", successfulReconnections.get());
- status.put("consecutiveFailures", consecutiveFailures.get());
- status.put("lastDisconnectTime", lastDisconnect != null ? lastDisconnect.toString() : null);
- status.put(
- "connectionEstablishedTime",
- connectionEstablished != null ? connectionEstablished.toString() : null);
- status.put("reconnectionStartTime", reconnectStart != null ? reconnectStart.toString() : null);
- status.put("lastErrorType", lastErrorType.get());
- status.put("lastMessageTime", lastMessage != null ? lastMessage.toString() : null);
- return status;
- }
-
- // ---------------------------------------------------------------------------
- // Graceful shutdown
- // ---------------------------------------------------------------------------
-
- /** Override in subclasses to add stream-specific stop logic (e.g. closing observers). */
- public void stop() {
- doStop();
- }
-
- /** Shared shutdown logic: set running flag, shut down channel. */
- protected void doStop() {
- isRunning.set(false);
- shutdown();
- }
-
- // ---------------------------------------------------------------------------
- // Configuration and channel management
- // ---------------------------------------------------------------------------
-
- public Config getConfig() {
- return currentConfig;
- }
-
- public boolean isUnconfigured() {
- if (currentConfig == null) {
- return true;
- }
- return currentConfig.isUnconfigured();
- }
-
- public Map getStatus() {
- return Map.of(
- "running", true,
- "configured", !getConfig().isUnconfigured(),
- "api_endpoint", getConfig().getApiEndpoint(),
- "org", getConfig().getOrg(),
- "expiration_date", getConfig().getExpirationDate(),
- "certificate_expiration_date", getConfig().getExpirationDate(),
- "certificate_description", getConfig().getCertificateDescription(),
- "channel_active", isChannelActive());
- }
-
- private boolean isChannelActive() {
- ManagedChannel channel = sharedChannel;
- return channel != null && !channel.isShutdown() && !channel.isTerminated();
- }
-
- protected void shutdown() {
- log.debug("Tenant: {} - Attempting shutdown of static shared gRPC channel.", tenant);
-
- lock.lock();
- try {
- ManagedChannel channelToShutdown = sharedChannel;
- if (channelToShutdown != null
- && !channelToShutdown.isShutdown()
- && !channelToShutdown.isTerminated()) {
- log.debug(
- "Tenant: {} - Attempting graceful shutdown of shared channel {}",
- tenant,
- channelToShutdown);
-
- channelToShutdown.shutdown();
- try {
- if (!channelToShutdown.awaitTermination(10, TimeUnit.SECONDS)) {
- log.warn(
- "Tenant: {} - Shared channel {} did not terminate gracefully after 10s, forcing shutdown.",
- tenant,
- channelToShutdown);
- channelToShutdown.shutdownNow();
- if (!channelToShutdown.awaitTermination(5, TimeUnit.SECONDS)) {
- log.error(
- "Tenant: {} - Shared channel {} failed to terminate even after forced shutdown.",
- tenant,
- channelToShutdown);
- }
- }
- log.info(
- "Tenant: {} - Successfully shut down shared channel {}", tenant, channelToShutdown);
- } catch (InterruptedException e) {
- log.error(
- "Tenant: {} - Interrupted while waiting for shared channel shutdown, forcing shutdown now.",
- tenant,
- e);
- channelToShutdown.shutdownNow();
- Thread.currentThread().interrupt();
- }
-
- sharedChannel = null;
- } else {
- log.debug("Tenant: {} - Shared channel is already null, shut down, or terminated.", tenant);
- if (sharedChannel != null && (sharedChannel.isShutdown() || sharedChannel.isTerminated())) {
- sharedChannel = null;
- }
- }
- } finally {
- lock.unlock();
- }
- }
-
- private void forceShutdownSharedChannel() {
- log.info("forceShudown called, aquiring lock");
- lock.lock();
- try {
- ManagedChannel channelToShutdown = sharedChannel;
- if (channelToShutdown != null) {
- log.info("Force shutting down shared gRPC channel during application shutdown");
- channelToShutdown.shutdownNow();
- try {
- if (!channelToShutdown.awaitTermination(5, TimeUnit.SECONDS)) {
- log.warn("Shared channel did not terminate within 5 seconds during force shutdown");
- }
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- log.warn("Interrupted during force shutdown of shared channel");
- }
- sharedChannel = null;
- }
- } finally {
- lock.unlock();
- }
- }
-
- public ManagedChannel getChannel() throws UnconfiguredException {
- long getChannelStartTime = System.currentTimeMillis();
- ManagedChannel localChannel = sharedChannel;
- if (localChannel == null || localChannel.isShutdown() || localChannel.isTerminated()) {
-
- long beforeLockAcquisition = System.currentTimeMillis();
- log.debug(
- "[LOCK-TIMING] getChannel attempting to acquire lock for tenant: {} at {}ms",
- tenant,
- beforeLockAcquisition);
-
- lock.lock();
-
- long afterLockAcquisition = System.currentTimeMillis();
- long lockWaitTime = afterLockAcquisition - beforeLockAcquisition;
- if (lockWaitTime > 100) {
- log.warn(
- "[LOCK-TIMING] getChannel lock acquired for tenant: {} after {}ms WAIT (potential contention!)",
- tenant,
- lockWaitTime);
- } else {
- log.debug(
- "[LOCK-TIMING] getChannel lock acquired for tenant: {} after {}ms",
- tenant,
- lockWaitTime);
- }
- try {
- localChannel = sharedChannel;
-
- var shutdown = localChannel == null || localChannel.isShutdown();
- var terminated = localChannel == null || localChannel.isTerminated();
-
- log.debug(
- "localChannel is null: {}, isShutdown: {}, isTerminated: {}",
- localChannel == null,
- shutdown,
- terminated);
-
- if (localChannel == null || localChannel.isShutdown() || localChannel.isTerminated()) {
- long beforeCreateChannel = System.currentTimeMillis();
- log.debug(
- "[LOCK-TIMING] getChannel creating new channel for tenant: {} at {}ms (lock held for {}ms so far)",
- tenant,
- beforeCreateChannel,
- beforeCreateChannel - afterLockAcquisition);
-
- localChannel = createNewChannel();
-
- long afterCreateChannel = System.currentTimeMillis();
- log.debug(
- "[LOCK-TIMING] getChannel channel created for tenant: {} at {}ms (createNewChannel took: {}ms, lock held total: {}ms)",
- tenant,
- afterCreateChannel,
- afterCreateChannel - beforeCreateChannel,
- afterCreateChannel - afterLockAcquisition);
-
- sharedChannel = localChannel;
- }
- } catch (Exception e) {
- log.error(
- "Tenant: {} - Error creating new static shared gRPC channel for config: {}",
- tenant,
- getConfig(),
- e);
- throw new UnconfiguredException("Error creating new static shared gRPC channel", e);
- } finally {
- long beforeUnlock = System.currentTimeMillis();
- lock.unlock();
- long afterUnlock = System.currentTimeMillis();
- long totalLockHeldTime = afterUnlock - afterLockAcquisition;
- log.debug(
- "[LOCK-TIMING] getChannel lock released for tenant: {} at {}ms (lock held for {}ms, total getChannel: {}ms)",
- tenant,
- afterUnlock,
- totalLockHeldTime,
- afterUnlock - getChannelStartTime);
- }
- } else {
- log.debug(
- "getChannel no new channel needed, returning localChannel null: {}, isShutdown: {}, isTerminated: {}",
- localChannel == null,
- localChannel.isShutdown(),
- localChannel.isTerminated());
- }
-
- return localChannel;
- }
-
- private ManagedChannel createNewChannel() throws UnconfiguredException {
- try {
- var channelCredentials =
- TlsChannelCredentials.newBuilder()
- .trustManager(new ByteArrayInputStream(getConfig().getRootCert().getBytes()))
- .keyManager(
- new ByteArrayInputStream(getConfig().getPublicCert().getBytes()),
- new ByteArrayInputStream(getConfig().getPrivateKey().getBytes()))
- .build();
-
- var hostname = getConfig().getApiHostname();
- var port = getConfig().getApiPort();
- var chan =
- Grpc.newChannelBuilderForAddress(hostname, port, channelCredentials)
- .keepAliveTime(32, TimeUnit.SECONDS)
- .keepAliveTimeout(30, TimeUnit.SECONDS)
- .keepAliveWithoutCalls(true)
- .idleTimeout(30, TimeUnit.MINUTES)
- .overrideAuthority("exile-proxy")
- .build();
- log.info("Managed Channel created for {}:{}", hostname, port);
-
- return chan;
-
- } catch (IOException e) {
- log.error("Tenant: {} - IOException during shared channel creation", tenant, e);
- throw new UnconfiguredException(
- "TCN Gate client configuration error during channel creation", e);
- } catch (UnconfiguredException e) {
- log.error("Tenant: {} - Configuration error during shared channel creation", tenant, e);
- throw e;
- } catch (Exception e) {
- log.error("Tenant: {} - Unexpected error during shared channel creation", tenant, e);
- throw new UnconfiguredException("Unexpected error configuring TCN Gate client channel", e);
- }
- }
-
- /**
- * Reset gRPC's internal connect backoff on the shared channel. This forces the name resolver to
- * immediately retry DNS resolution instead of waiting for its own exponential backoff timer.
- */
- protected void resetChannelBackoff() {
- ManagedChannel channel = sharedChannel;
- if (channel != null && !channel.isShutdown() && !channel.isTerminated()) {
- channel.resetConnectBackoff();
- }
- }
-
- /** Resets the gRPC channel after a connection failure. */
- protected void resetChannel() {
- log.info("Tenant: {} - Resetting static shared gRPC channel after connection failure.", tenant);
- shutdown();
- }
-
- /** Handle StatusRuntimeException, resetting channel on UNAVAILABLE. */
- protected boolean handleStatusRuntimeException(StatusRuntimeException e) {
- if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) {
- log.warn(
- "Tenant: {} - Connection unavailable, resetting channel: {}", tenant, e.getMessage());
- resetChannel();
- return true;
- }
- return false;
- }
-
- /** Get channel statistics for monitoring. */
- public Map getChannelStats() {
- ManagedChannel channel = sharedChannel;
- if (channel != null) {
- return Map.of(
- "isShutdown", channel.isShutdown(),
- "isTerminated", channel.isTerminated(),
- "authority", channel.authority(),
- "state", channel.getState(false).toString());
- }
- return Map.of("channel", "null");
- }
-
- /** Exception for hung connection detection. */
- public static class HungConnectionException extends Exception {
- public HungConnectionException(String message) {
- super(message);
- }
- }
-
- private boolean isRoutineStreamClosure(Exception e) {
- Throwable cause = e;
- while (cause != null) {
- if (cause instanceof StatusRuntimeException sre
- && sre.getStatus().getCode() == Status.Code.UNAVAILABLE
- && sre.getMessage() != null
- && sre.getMessage().contains("NO_ERROR")) {
- return true;
- }
- cause = cause.getCause();
- }
- return false;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java
deleted file mode 100644
index 33c7ed4..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc;
-import build.buf.gen.tcnapi.exile.gate.v2.GetClientConfigurationRequest;
-import build.buf.gen.tcnapi.exile.gate.v2.GetClientConfigurationResponse;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.models.PluginConfigEvent;
-import com.tcn.exile.plugin.PluginInterface;
-import io.grpc.StatusRuntimeException;
-import io.micronaut.scheduling.annotation.Scheduled;
-import java.util.concurrent.TimeUnit;
-
-public class GateClientConfiguration extends GateClientAbstract {
- PluginConfigEvent event = null;
-
- PluginInterface plugin;
-
- protected static final org.slf4j.Logger log =
- org.slf4j.LoggerFactory.getLogger(GateClientConfiguration.class);
-
- /** Redacts sensitive information from JSON configuration payload. */
- private String redactConfigPayloadForLogging(String payload) {
- if (payload == null || payload.isBlank()) {
- return payload;
- }
-
- // Redact password field
- String redacted =
- payload.replaceAll(
- "\"database_password\"\\s*:\\s*\"[^\"]*\"", "\"database_password\":\"***REDACTED***\"");
-
- // Redact username field
- redacted =
- redacted.replaceAll(
- "\"database_username\"\\s*:\\s*\"[^\"]*\"", "\"database_username\":\"***REDACTED***\"");
-
- // Redact jdbcUser field
- redacted =
- redacted.replaceAll("\"jdbcUser\"\\s*:\\s*\"[^\"]*\"", "\"jdbcUser\":\"***REDACTED***\"");
-
- // Redact certificate fields
- redacted =
- redacted.replaceAll(
- "\"trust_store_cert\"\\s*:\\s*\"[^\"]*\"",
- "\"trust_store_cert\":\"***CERTIFICATE_REDACTED***\"");
- redacted =
- redacted.replaceAll(
- "\"key_store_cert\"\\s*:\\s*\"[^\"]*\"",
- "\"key_store_cert\":\"***CERTIFICATE_REDACTED***\"");
-
- return redacted;
- }
-
- /** Creates a redacted version of PluginConfigEvent for safe logging. */
- private String redactEventForLogging(PluginConfigEvent event) {
- if (event == null) {
- return "null";
- }
-
- return String.format(
- "PluginConfigEvent{orgId='%s', orgName='%s', configurationName='%s', configurationPayload='%s', unconfigured=%s}",
- event.getOrgId(),
- event.getOrgName(),
- event.getConfigurationName(),
- redactConfigPayloadForLogging(event.getConfigurationPayload()),
- event.isUnconfigured());
- }
-
- /** Creates a redacted version of GetClientConfigurationResponse for safe logging. */
- private String redactResponseForLogging(GetClientConfigurationResponse response) {
- if (response == null) {
- return "null";
- }
-
- return String.format(
- "GetClientConfigurationResponse{orgId='%s', orgName='%s', configName='%s', configPayload='%s'}",
- response.getOrgId(),
- response.getOrgName(),
- response.getConfigName(),
- redactConfigPayloadForLogging(response.getConfigPayload()));
- }
-
- public GateClientConfiguration(String tenant, Config currentConfig, PluginInterface plugin) {
- super(tenant, currentConfig);
- this.plugin = plugin;
- this.event = new PluginConfigEvent(this).setOrgId(currentConfig.getOrg()).setUnconfigured(true);
- }
-
- @Override
- @Scheduled(fixedDelay = "10s")
- public void start() {
- try {
- var client =
- GateServiceGrpc.newBlockingStub(getChannel())
- .withDeadlineAfter(300, TimeUnit.SECONDS)
- .withWaitForReady();
- GetClientConfigurationResponse response =
- client.getClientConfiguration(GetClientConfigurationRequest.newBuilder().build());
-
- log.debug(
- "Tenant: {} got config response: {} current event: {}",
- this.tenant,
- redactResponseForLogging(response),
- redactEventForLogging(this.event));
- var newEvent =
- new PluginConfigEvent(this)
- .setConfigurationName(response.getConfigName())
- .setConfigurationPayload(response.getConfigPayload())
- .setOrgId(response.getOrgId())
- .setOrgName(response.getOrgName())
- .setUnconfigured(false);
- if (!newEvent.equals(event)) {
- log.debug(
- "Tenant: {} - Received new configuration event: {}, update plugin config",
- tenant,
- redactEventForLogging(newEvent));
- event = newEvent;
- this.plugin.setConfig(event);
- }
- } catch (UnconfiguredException e) {
- log.debug("Tenant: {} - Configuration not set, skipping get client configuration", tenant);
- } catch (StatusRuntimeException e) {
- if (!handleStatusRuntimeException(e)) {
- log.error("Tenant: {} - Failed to get client configuration", tenant, e);
- } else {
- log.warn(
- "Tenant: {} - Transient gRPC error while getting client configuration, will retry",
- tenant,
- e);
- }
- }
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java
deleted file mode 100644
index 39e6011..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java
+++ /dev/null
@@ -1,413 +0,0 @@
-/*
- * (C) 2017-2026 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.Event;
-import build.buf.gen.tcnapi.exile.gate.v2.EventStreamRequest;
-import build.buf.gen.tcnapi.exile.gate.v2.EventStreamResponse;
-import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.log.LogCategory;
-import com.tcn.exile.log.StructuredLogger;
-import com.tcn.exile.plugin.PluginInterface;
-import io.grpc.stub.StreamObserver;
-import jakarta.annotation.PreDestroy;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-
-/**
- * Bidirectional event streaming with acknowledgment using the EventStream API.
- *
- * The client drives the loop: sends a request (with ACK IDs + event count), server responds with
- * events, client processes them and sends the next request with ACKs. This repeats until the
- * server's 5-minute context timeout expires.
- */
-public class GateClientEventStream extends GateClientAbstract {
- private static final StructuredLogger log = new StructuredLogger(GateClientEventStream.class);
-
- private static final int BATCH_SIZE = 100;
-
- private final PluginInterface plugin;
- private final AtomicLong eventsProcessed = new AtomicLong(0);
- private final AtomicLong eventsFailed = new AtomicLong(0);
-
- // Pending ACKs buffer — survives stream reconnections.
- // Event IDs are added after successful processing and removed only after
- // successful send to the server. If a stream breaks before ACKs are sent,
- // they carry over to the next connection attempt.
- private final List pendingAcks = new ArrayList<>();
-
- // Stream observer for sending requests/ACKs
- private final AtomicReference> requestObserverRef =
- new AtomicReference<>();
-
- public GateClientEventStream(String tenant, Config currentConfig, PluginInterface plugin) {
- super(tenant, currentConfig);
- this.plugin = plugin;
- }
-
- @Override
- protected String getStreamName() {
- return "EventStream";
- }
-
- @Override
- protected void onStreamDisconnected() {
- requestObserverRef.set(null);
- }
-
- @Override
- protected void runStream()
- throws UnconfiguredException, InterruptedException, HungConnectionException {
- var latch = new CountDownLatch(1);
- var errorRef = new AtomicReference();
- var firstResponseReceived = new AtomicBoolean(false);
-
- var responseObserver =
- new StreamObserver() {
- @Override
- public void onNext(EventStreamResponse response) {
- lastMessageTime.set(Instant.now());
-
- if (firstResponseReceived.compareAndSet(false, true)) {
- onConnectionEstablished();
- }
-
- if (response.getEventsCount() == 0) {
- log.debug(
- LogCategory.GRPC, "EmptyBatch", "Received empty event batch, requesting next");
- // Brief pause to avoid hot-looping when no events are available.
- // The server responds instantly to empty polls, so without this
- // delay we'd loop every ~40ms burning CPU and network.
- try {
- Thread.sleep(10000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- return;
- }
- sendNextRequest();
- return;
- }
-
- log.debug(
- LogCategory.GRPC,
- "EventsReceived",
- "Received %d events from stream",
- response.getEventsCount());
-
- processBatch(response.getEventsList());
- sendNextRequest();
- }
-
- @Override
- public void onError(Throwable t) {
- log.warn(LogCategory.GRPC, "StreamError", "Event stream error: %s", t.getMessage());
- errorRef.set(t);
- latch.countDown();
- }
-
- @Override
- public void onCompleted() {
- log.info(LogCategory.GRPC, "StreamCompleted", "Event stream completed by server");
- latch.countDown();
- }
- };
-
- // Open bidirectional stream
- var requestObserver = GateServiceGrpc.newStub(getChannel()).eventStream(responseObserver);
- requestObserverRef.set(requestObserver);
-
- // Send initial request — include any pending ACKs from a previous broken stream
- List carryOverAcks;
- synchronized (pendingAcks) {
- carryOverAcks = new ArrayList<>(pendingAcks);
- }
- if (!carryOverAcks.isEmpty()) {
- log.info(
- LogCategory.GRPC,
- "CarryOverAcks",
- "Sending %d pending ACKs from previous stream session",
- carryOverAcks.size());
- }
- log.debug(LogCategory.GRPC, "Init", "Sending initial event stream request...");
- requestObserver.onNext(
- EventStreamRequest.newBuilder()
- .setEventCount(BATCH_SIZE)
- .addAllAckEventIds(carryOverAcks)
- .build());
- if (!carryOverAcks.isEmpty()) {
- synchronized (pendingAcks) {
- pendingAcks.removeAll(carryOverAcks);
- }
- }
-
- awaitStreamWithHungDetection(latch);
-
- var error = errorRef.get();
- if (error != null) {
- throw new RuntimeException("Stream error", error);
- }
- }
-
- // ---------------------------------------------------------------------------
- // Request & ACK management
- // ---------------------------------------------------------------------------
-
- /**
- * Send the next request on the stream, draining pendingAcks into the request. If the send fails,
- * the ACKs remain in pendingAcks for the next attempt or reconnection.
- */
- private void sendNextRequest() {
- var observer = requestObserverRef.get();
- if (observer == null) {
- log.warn(LogCategory.GRPC, "RequestFailed", "Cannot send next request - no active observer");
- return;
- }
-
- List acksToSend;
- synchronized (pendingAcks) {
- acksToSend = new ArrayList<>(pendingAcks);
- }
-
- try {
- var request =
- EventStreamRequest.newBuilder()
- .setEventCount(BATCH_SIZE)
- .addAllAckEventIds(acksToSend)
- .build();
- observer.onNext(request);
-
- if (!acksToSend.isEmpty()) {
- synchronized (pendingAcks) {
- pendingAcks.removeAll(acksToSend);
- }
- log.debug(
- LogCategory.GRPC,
- "AckSent",
- "Sent ACK for %d events, requesting next batch",
- acksToSend.size());
- }
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "RequestFailed",
- "Failed to send next event stream request (keeping %d pending ACKs): %s",
- acksToSend.size(),
- e.getMessage());
- }
- }
-
- // ---------------------------------------------------------------------------
- // Event processing
- // ---------------------------------------------------------------------------
-
- /** Process a batch of events, adding successful ACK IDs to the pending buffer. */
- private void processBatch(List events) {
- long batchStart = System.currentTimeMillis();
- int successCount = 0;
-
- for (Event event : events) {
- String eventId = getEventId(event);
- if (processEvent(event)) {
- synchronized (pendingAcks) {
- pendingAcks.add(eventId);
- }
- successCount++;
- eventsProcessed.incrementAndGet();
- } else {
- eventsFailed.incrementAndGet();
- log.warn(
- LogCategory.GRPC,
- "EventNotAcked",
- "Event %s NOT acknowledged - will be redelivered",
- eventId);
- }
- }
-
- long elapsed = System.currentTimeMillis() - batchStart;
- if (successCount > 0 && (elapsed / successCount) > 1000) {
- log.warn(
- LogCategory.GRPC,
- "SlowBatch",
- "Event batch completed %d events in %dms, avg %dms per event",
- successCount,
- elapsed,
- elapsed / successCount);
- }
- }
-
- /**
- * Process a single event by dispatching to the plugin. Returns true if successfully processed.
- */
- private boolean processEvent(Event event) {
- try {
- if (!plugin.isRunning()) {
- log.debug(
- LogCategory.GRPC,
- "PluginNotRunning",
- "Plugin is not running, skipping event processing");
- return false;
- }
-
- switch (event.getEntityCase()) {
- case AGENT_CALL:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received agent call event %d - %s",
- event.getAgentCall().getCallSid(),
- event.getAgentCall().getCallType());
- plugin.handleAgentCall(event.getAgentCall());
- break;
- case AGENT_RESPONSE:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received agent response event %s",
- event.getAgentResponse().getAgentCallResponseSid());
- plugin.handleAgentResponse(event.getAgentResponse());
- break;
- case TELEPHONY_RESULT:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received telephony result event %d - %s",
- event.getTelephonyResult().getCallSid(),
- event.getTelephonyResult().getCallType());
- plugin.handleTelephonyResult(event.getTelephonyResult());
- break;
- case TASK:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received task event %s",
- event.getTask().getTaskSid());
- plugin.handleTask(event.getTask());
- break;
- case TRANSFER_INSTANCE:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received transfer instance event %s",
- event.getTransferInstance().getTransferInstanceId());
- plugin.handleTransferInstance(event.getTransferInstance());
- break;
- case CALL_RECORDING:
- log.debug(
- LogCategory.GRPC,
- "EventProcessed",
- "Received call recording event %s",
- event.getCallRecording().getRecordingId());
- plugin.handleCallRecording(event.getCallRecording());
- break;
- default:
- log.warn(
- LogCategory.GRPC, "UnknownEvent", "Unknown event type: %s", event.getEntityCase());
- break;
- }
-
- return true;
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "EventProcessingError",
- "Failed to process event: %s",
- e.getMessage(),
- e);
- return false;
- }
- }
-
- /**
- * Extract the server-assigned entity ID for ACK purposes. The server sets exile_entity_id on each
- * Event from the DB's entity_id, which may be a composite key (e.g. "call_type,call_sid" for
- * telephony). We must echo this exact value back for the ACK to match.
- */
- private String getEventId(Event event) {
- String entityId = event.getExileEntityId();
- if (entityId == null || entityId.isEmpty()) {
- log.warn(
- LogCategory.GRPC,
- "MissingEntityId",
- "Event has no exile_entity_id, cannot ACK. Event type: %s",
- event.getEntityCase());
- return "unknown";
- }
- return entityId;
- }
-
- // ---------------------------------------------------------------------------
- // Lifecycle
- // ---------------------------------------------------------------------------
-
- @Override
- public void stop() {
- log.info(
- LogCategory.GRPC,
- "Stopping",
- "Stopping GateClientEventStream (total attempts: %d, successful: %d, events: %d/%d)",
- totalReconnectionAttempts.get(),
- successfulReconnections.get(),
- eventsProcessed.get(),
- eventsFailed.get());
-
- synchronized (pendingAcks) {
- if (!pendingAcks.isEmpty()) {
- log.warn(
- LogCategory.GRPC,
- "UnsentAcks",
- "Shutting down with %d un-sent ACKs (these events will be redelivered): %s",
- pendingAcks.size(),
- pendingAcks);
- }
- }
-
- var observer = requestObserverRef.get();
- if (observer != null) {
- try {
- observer.onCompleted();
- } catch (Exception e) {
- log.debug(LogCategory.GRPC, "CloseError", "Error closing stream: %s", e.getMessage());
- }
- }
-
- doStop();
- log.info(LogCategory.GRPC, "Stopped", "GateClientEventStream stopped");
- }
-
- @PreDestroy
- public void destroy() {
- stop();
- }
-
- public Map getStreamStatus() {
- Map status = buildStreamStatus();
- status.put("eventsProcessed", eventsProcessed.get());
- status.put("eventsFailed", eventsFailed.get());
- synchronized (pendingAcks) {
- status.put("pendingAckCount", pendingAcks.size());
- }
- return status;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java
deleted file mode 100644
index b9b2784..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java
+++ /dev/null
@@ -1,364 +0,0 @@
-/*
- * (C) 2017-2026 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc;
-import build.buf.gen.tcnapi.exile.gate.v2.JobQueueStreamRequest;
-import build.buf.gen.tcnapi.exile.gate.v2.JobQueueStreamResponse;
-import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse;
-import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.log.LogCategory;
-import com.tcn.exile.log.StructuredLogger;
-import com.tcn.exile.plugin.PluginInterface;
-import io.grpc.stub.StreamObserver;
-import jakarta.annotation.PreDestroy;
-import java.time.Instant;
-import java.util.Map;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-
-/** Bidirectional job streaming with acknowledgment using the JobQueueStream API. */
-public class GateClientJobQueue extends GateClientAbstract {
- private static final StructuredLogger log = new StructuredLogger(GateClientJobQueue.class);
- private static final int DEFAULT_TIMEOUT_SECONDS = 300;
- private static final String KEEPALIVE_JOB_ID = "keepalive";
-
- private final PluginInterface plugin;
- private final AtomicLong jobsProcessed = new AtomicLong(0);
- private final AtomicLong jobsFailed = new AtomicLong(0);
-
- // Stream observer for sending ACKs
- private final AtomicReference> requestObserverRef =
- new AtomicReference<>();
-
- public GateClientJobQueue(String tenant, Config currentConfig, PluginInterface plugin) {
- super(tenant, currentConfig);
- this.plugin = plugin;
- }
-
- @Override
- protected String getStreamName() {
- return "JobQueue";
- }
-
- @Override
- protected void onStreamDisconnected() {
- requestObserverRef.set(null);
- }
-
- @Override
- protected void runStream()
- throws UnconfiguredException, InterruptedException, HungConnectionException {
- var latch = new CountDownLatch(1);
- var errorRef = new AtomicReference();
- var firstResponseReceived = new AtomicBoolean(false);
-
- var responseObserver =
- new StreamObserver() {
- @Override
- public void onNext(JobQueueStreamResponse response) {
- lastMessageTime.set(Instant.now());
-
- if (firstResponseReceived.compareAndSet(false, true)) {
- onConnectionEstablished();
- }
-
- if (!response.hasJob()) {
- log.debug(LogCategory.GRPC, "Heartbeat", "Received heartbeat from server");
- return;
- }
-
- var job = response.getJob();
- String jobId = job.getJobId();
-
- // Handle keepalive — must ACK to register with presence store
- if (KEEPALIVE_JOB_ID.equals(jobId)) {
- log.debug(
- LogCategory.GRPC, "Keepalive", "Received keepalive, sending ACK to register");
- sendAck(KEEPALIVE_JOB_ID);
- if (job.hasInfo()) {
- try {
- plugin.info(KEEPALIVE_JOB_ID, job.getInfo());
- } catch (Exception e) {
- log.warn(
- LogCategory.GRPC,
- "KeepaliveInfo",
- "Failed to dispatch info on keepalive: %s",
- e.getMessage());
- }
- }
- return;
- }
-
- log.debug(
- LogCategory.GRPC,
- "JobReceived",
- "Received job: %s (type: %s)",
- jobId,
- getJobType(job));
-
- if (processJob(job)) {
- sendAck(jobId);
- jobsProcessed.incrementAndGet();
- } else {
- jobsFailed.incrementAndGet();
- log.warn(
- LogCategory.GRPC,
- "JobNotAcked",
- "Job %s NOT acknowledged - will be redelivered to another client",
- jobId);
- }
- }
-
- @Override
- public void onError(Throwable t) {
- log.warn(LogCategory.GRPC, "StreamError", "Job queue stream error: %s", t.getMessage());
- errorRef.set(t);
- latch.countDown();
- }
-
- @Override
- public void onCompleted() {
- log.info(LogCategory.GRPC, "StreamCompleted", "Job queue stream completed by server");
- latch.countDown();
- }
- };
-
- // Open bidirectional stream
- var requestObserver = GateServiceGrpc.newStub(getChannel()).jobQueueStream(responseObserver);
- requestObserverRef.set(requestObserver);
-
- // Send initial keepalive to register with server
- log.debug(LogCategory.GRPC, "Init", "Sending initial keepalive to job queue...");
- requestObserver.onNext(JobQueueStreamRequest.newBuilder().setJobId(KEEPALIVE_JOB_ID).build());
-
- awaitStreamWithHungDetection(latch);
-
- var error = errorRef.get();
- if (error != null) {
- throw new RuntimeException("Stream error", error);
- }
- }
-
- // ---------------------------------------------------------------------------
- // ACK management
- // ---------------------------------------------------------------------------
-
- /** Send acknowledgment for a job. */
- private void sendAck(String jobId) {
- var observer = requestObserverRef.get();
- if (observer == null) {
- log.warn(
- LogCategory.GRPC, "AckFailed", "Cannot send ACK for job %s - no active observer", jobId);
- return;
- }
- try {
- observer.onNext(JobQueueStreamRequest.newBuilder().setJobId(jobId).build());
- log.debug(LogCategory.GRPC, "AckSent", "Sent ACK for job: %s", jobId);
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "AckFailed",
- "Failed to send ACK for job %s: %s",
- jobId,
- e.getMessage());
- }
- }
-
- // ---------------------------------------------------------------------------
- // Job processing
- // ---------------------------------------------------------------------------
-
- /** Process a job by dispatching to the plugin. Returns true if successfully processed. */
- private boolean processJob(StreamJobsResponse value) {
- long jobStartTime = System.currentTimeMillis();
-
- try {
- boolean adminJob = isAdminJob(value);
- if (!adminJob && !plugin.isRunning()) {
- log.warn(
- LogCategory.GRPC,
- "JobRejected",
- "Skipping job %s because database is unavailable (only admin jobs can run)",
- value.getJobId());
- submitJobError(value.getJobId(), "Database unavailable; only admin jobs can run");
- return false;
- }
-
- if (value.hasListPools()) {
- plugin.listPools(value.getJobId(), value.getListPools());
- } else if (value.hasGetPoolStatus()) {
- plugin.getPoolStatus(value.getJobId(), value.getGetPoolStatus());
- } else if (value.hasGetPoolRecords()) {
- plugin.getPoolRecords(value.getJobId(), value.getGetPoolRecords());
- } else if (value.hasSearchRecords()) {
- plugin.searchRecords(value.getJobId(), value.getSearchRecords());
- } else if (value.hasGetRecordFields()) {
- plugin.readFields(value.getJobId(), value.getGetRecordFields());
- } else if (value.hasSetRecordFields()) {
- plugin.writeFields(value.getJobId(), value.getSetRecordFields());
- } else if (value.hasCreatePayment()) {
- plugin.createPayment(value.getJobId(), value.getCreatePayment());
- } else if (value.hasPopAccount()) {
- plugin.popAccount(value.getJobId(), value.getPopAccount());
- } else if (value.hasInfo()) {
- plugin.info(value.getJobId(), value.getInfo());
- } else if (value.hasShutdown()) {
- plugin.shutdown(value.getJobId(), value.getShutdown());
- } else if (value.hasLogging()) {
- plugin.logger(value.getJobId(), value.getLogging());
- } else if (value.hasExecuteLogic()) {
- plugin.executeLogic(value.getJobId(), value.getExecuteLogic());
- } else if (value.hasDiagnostics()) {
- plugin.runDiagnostics(value.getJobId(), value.getDiagnostics());
- } else if (value.hasListTenantLogs()) {
- plugin.listTenantLogs(value.getJobId(), value.getListTenantLogs());
- } else if (value.hasSetLogLevel()) {
- plugin.setLogLevel(value.getJobId(), value.getSetLogLevel());
- } else {
- log.error(
- LogCategory.GRPC, "UnknownJobType", "Unknown job type: %s", value.getUnknownFields());
- }
-
- long jobDuration = System.currentTimeMillis() - jobStartTime;
- log.debug(
- LogCategory.GRPC,
- "JobCompleted",
- "Processed job %s in %d ms",
- value.getJobId(),
- jobDuration);
- return true;
-
- } catch (UnconfiguredException e) {
- long jobDuration = System.currentTimeMillis() - jobStartTime;
- log.error(
- LogCategory.GRPC,
- "JobHandlingError",
- "Error while handling job: %s (took %d ms)",
- value.getJobId(),
- jobDuration,
- e);
- return false;
- } catch (Exception e) {
- long jobDuration = System.currentTimeMillis() - jobStartTime;
- log.error(
- LogCategory.GRPC,
- "UnexpectedJobError",
- "Unexpected error while handling job: %s (took %d ms)",
- value.getJobId(),
- jobDuration,
- e);
- return false;
- }
- }
-
- private String getJobType(StreamJobsResponse job) {
- if (job.hasListPools()) return "listPools";
- if (job.hasGetPoolStatus()) return "getPoolStatus";
- if (job.hasGetPoolRecords()) return "getPoolRecords";
- if (job.hasSearchRecords()) return "searchRecords";
- if (job.hasGetRecordFields()) return "getRecordFields";
- if (job.hasSetRecordFields()) return "setRecordFields";
- if (job.hasCreatePayment()) return "createPayment";
- if (job.hasPopAccount()) return "popAccount";
- if (job.hasInfo()) return "info";
- if (job.hasShutdown()) return "shutdown";
- if (job.hasLogging()) return "logging";
- if (job.hasExecuteLogic()) return "executeLogic";
- if (job.hasDiagnostics()) return "diagnostics";
- if (job.hasListTenantLogs()) return "listTenantLogs";
- if (job.hasSetLogLevel()) return "setLogLevel";
- return "unknown";
- }
-
- private boolean isAdminJob(StreamJobsResponse value) {
- return value.hasDiagnostics()
- || value.hasListTenantLogs()
- || value.hasSetLogLevel()
- || value.hasShutdown()
- || value.hasInfo();
- }
-
- private void submitJobError(String jobId, String message) {
- try {
- SubmitJobResultsRequest request =
- SubmitJobResultsRequest.newBuilder()
- .setJobId(jobId)
- .setEndOfTransmission(true)
- .setErrorResult(
- SubmitJobResultsRequest.ErrorResult.newBuilder().setMessage(message).build())
- .build();
-
- GateServiceGrpc.newBlockingStub(getChannel())
- .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .withWaitForReady()
- .submitJobResults(request);
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "SubmitJobErrorFailed",
- "Failed to submit error for job %s: %s",
- jobId,
- e.getMessage());
- }
- }
-
- // ---------------------------------------------------------------------------
- // Lifecycle
- // ---------------------------------------------------------------------------
-
- @Override
- public void stop() {
- log.info(
- LogCategory.GRPC,
- "Stopping",
- "Stopping GateClientJobQueue (total attempts: %d, successful: %d, jobs: %d/%d)",
- totalReconnectionAttempts.get(),
- successfulReconnections.get(),
- jobsProcessed.get(),
- jobsFailed.get());
-
- var observer = requestObserverRef.get();
- if (observer != null) {
- try {
- observer.onCompleted();
- } catch (Exception e) {
- log.debug(LogCategory.GRPC, "CloseError", "Error closing stream: %s", e.getMessage());
- }
- }
-
- doStop();
- log.info(LogCategory.GRPC, "Stopped", "GateClientJobQueue stopped");
- }
-
- @PreDestroy
- public void destroy() {
- stop();
- }
-
- public Map getStreamStatus() {
- Map status = buildStreamStatus();
- status.put("jobsProcessed", jobsProcessed.get());
- status.put("jobsFailed", jobsFailed.get());
- return status;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java
deleted file mode 100644
index 3ea6f87..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java
+++ /dev/null
@@ -1,351 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc;
-import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsRequest;
-import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse;
-import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.log.LogCategory;
-import com.tcn.exile.log.StructuredLogger;
-import com.tcn.exile.plugin.PluginInterface;
-import io.grpc.ConnectivityState;
-import io.grpc.ManagedChannel;
-import io.grpc.stub.StreamObserver;
-import jakarta.annotation.PreDestroy;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.ExecutorCompletionService;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
-
-/**
- * @deprecated Use {@link GateClientJobQueue} instead. This class uses the deprecated StreamJobs
- * server-streaming API which has been replaced by the JobQueueStream bidirectional API with
- * acknowledgment support.
- */
-@Deprecated
-public class GateClientJobStream extends GateClientAbstract
- implements StreamObserver {
- private static final StructuredLogger log = new StructuredLogger(GateClientJobStream.class);
- private static final int DEFAULT_TIMEOUT_SECONDS = 300;
-
- private final PluginInterface plugin;
- private final AtomicBoolean establishedForCurrentAttempt = new AtomicBoolean(false);
- private final AtomicReference lastMessageTime = new AtomicReference<>();
-
- // Connection timing tracking
- private final AtomicReference lastDisconnectTime = new AtomicReference<>();
- private final AtomicReference reconnectionStartTime = new AtomicReference<>();
- private final AtomicReference connectionEstablishedTime = new AtomicReference<>();
- private final AtomicLong totalReconnectionAttempts = new AtomicLong(0);
- private final AtomicLong successfulReconnections = new AtomicLong(0);
- private final AtomicReference lastErrorType = new AtomicReference<>();
- private final AtomicLong consecutiveFailures = new AtomicLong(0);
- private final AtomicBoolean isRunning = new AtomicBoolean(false);
-
- public GateClientJobStream(String tenant, Config currentConfig, PluginInterface plugin) {
- super(tenant, currentConfig);
- this.plugin = plugin;
- }
-
- private boolean channelIsDead(ConnectivityState state) {
- return state.compareTo(ConnectivityState.TRANSIENT_FAILURE) == 0
- || state.compareTo(ConnectivityState.SHUTDOWN) == 0;
- }
-
- private boolean lastJobReceivedTooLongAgo(Instant lastMessageTime) {
- return lastMessageTime != null
- && lastMessageTime.isBefore(Instant.now().minus(45, ChronoUnit.SECONDS));
- }
-
- private void blowupIfOurConnectionIsHung(ManagedChannel channel) throws Exception {
- var state = channel.getState(false);
- if (channelIsDead(state)) {
- throw new Exception("JobStream channel is in state " + state.toString());
- }
- var lastJobTime = lastMessageTime.get();
- var startOfStream = connectionEstablishedTime.get();
-
- // if we haven't received a job ever, then test when we started the stream
- if (lastJobTime == null && lastJobReceivedTooLongAgo(startOfStream)) {
- throw new Exception(
- "JobStream never received any messages since start time at: "
- + startOfStream.toString()
- + " connection is hung");
- }
- if (lastJobReceivedTooLongAgo(lastJobTime)) {
- throw new Exception(
- "JobStream received last job too long ago at: "
- + lastJobTime.toString()
- + " connection is hung");
- }
- }
-
- public void start() {
- if (isUnconfigured()) {
- log.warn(LogCategory.GRPC, "NOOP", "JobStream is unconfigured, cannot stream jobs");
- return;
- }
-
- ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
- ExecutorCompletionService completionService =
- new ExecutorCompletionService<>(executorService);
-
- try {
- log.debug(LogCategory.GRPC, "Init", "JobStream task started, checking configuration status");
-
- log.debug(LogCategory.GRPC, "Start", "Starting JobStream");
- reconnectionStartTime.set(Instant.now());
- ManagedChannel channel = getChannel();
-
- var client =
- GateServiceGrpc.newBlockingStub(channel)
- .withWaitForReady()
- .streamJobs(StreamJobsRequest.newBuilder().build());
-
- connectionEstablishedTime.set(Instant.now());
- successfulReconnections.incrementAndGet();
- isRunning.set(true);
- consecutiveFailures.set(0);
- lastErrorType.set(null);
-
- log.info(
- LogCategory.GRPC,
- "ConnectionEstablished",
- "Job stream connection took {}",
- Duration.between(reconnectionStartTime.get(), connectionEstablishedTime.get()));
-
- completionService.submit(
- () -> {
- client.forEachRemaining(this::onNext);
- return null;
- });
- completionService.submit(
- () -> {
- while (isRunning.get()) {
- Thread.sleep(45000);
- blowupIfOurConnectionIsHung(channel);
- }
- return null;
- });
- // blow up or wait
- completionService.take();
-
- } catch (Exception e) {
- log.error(LogCategory.GRPC, "JobStream", "error streaming jobs from server: {}", e);
- lastErrorType.set(e.getClass().getSimpleName());
- if (connectionEstablishedTime.get() == null) {
- consecutiveFailures.incrementAndGet();
- }
- } finally {
- totalReconnectionAttempts.incrementAndGet();
- lastDisconnectTime.set(Instant.now());
- isRunning.set(false);
- log.debug(LogCategory.GRPC, "Complete", "Job stream done");
-
- executorService.shutdownNow();
- }
- }
-
- @Override
- public void onNext(StreamJobsResponse value) {
- long jobStartTime = System.currentTimeMillis();
- log.debug(LogCategory.GRPC, "JobReceived", "Received job: %s", value.getJobId());
- lastMessageTime.set(Instant.now());
-
- try {
- boolean adminJob = isAdminJob(value);
- if (!adminJob && !plugin.isRunning()) {
- log.warn(
- LogCategory.GRPC,
- "JobRejected",
- "Skipping job %s because database is unavailable (only admin jobs can run)",
- value.getJobId());
- submitJobError(value.getJobId(), "Database unavailable; only admin jobs can run");
- return;
- }
-
- if (value.hasListPools()) {
- plugin.listPools(value.getJobId(), value.getListPools());
- } else if (value.hasGetPoolStatus()) {
- plugin.getPoolStatus(value.getJobId(), value.getGetPoolStatus());
- } else if (value.hasGetPoolRecords()) {
- plugin.getPoolRecords(value.getJobId(), value.getGetPoolRecords());
- } else if (value.hasSearchRecords()) {
- plugin.searchRecords(value.getJobId(), value.getSearchRecords());
- } else if (value.hasGetRecordFields()) {
- plugin.readFields(value.getJobId(), value.getGetRecordFields());
- } else if (value.hasSetRecordFields()) {
- plugin.writeFields(value.getJobId(), value.getSetRecordFields());
- } else if (value.hasCreatePayment()) {
- plugin.createPayment(value.getJobId(), value.getCreatePayment());
- } else if (value.hasPopAccount()) {
- plugin.popAccount(value.getJobId(), value.getPopAccount());
- } else if (value.hasInfo()) {
- plugin.info(value.getJobId(), value.getInfo());
- } else if (value.hasShutdown()) {
- plugin.shutdown(value.getJobId(), value.getShutdown());
- } else if (value.hasLogging()) {
- plugin.logger(value.getJobId(), value.getLogging());
- } else if (value.hasExecuteLogic()) {
- plugin.executeLogic(value.getJobId(), value.getExecuteLogic());
- } else if (value.hasDiagnostics()) {
- plugin.runDiagnostics(value.getJobId(), value.getDiagnostics());
- } else if (value.hasListTenantLogs()) {
- plugin.listTenantLogs(value.getJobId(), value.getListTenantLogs());
- } else if (value.hasSetLogLevel()) {
- plugin.setLogLevel(value.getJobId(), value.getSetLogLevel());
- } else {
- log.error(
- LogCategory.GRPC, "UnknownJobType", "Unknown job type: %s", value.getUnknownFields());
- }
- } catch (UnconfiguredException e) {
- long jobDuration = System.currentTimeMillis() - jobStartTime;
- log.error(
- LogCategory.GRPC,
- "JobHandlingError",
- "Error while handling job: %s (took %d ms)",
- value.getJobId(),
- jobDuration,
- e);
- } catch (Exception e) {
- long jobDuration = System.currentTimeMillis() - jobStartTime;
- log.error(
- LogCategory.GRPC,
- "UnexpectedJobError",
- "Unexpected error while handling job: %s (took %d ms)",
- value.getJobId(),
- jobDuration,
- e);
- }
- }
-
- @Override
- public void onError(Throwable t) {
- log.error(LogCategory.GRPC, "JobStreamError", "onError received: {}", t);
- }
-
- @Override
- public void onCompleted() {
- Instant disconnectTime = Instant.now();
- lastDisconnectTime.set(disconnectTime);
- lastMessageTime.set(disconnectTime);
-
- // Calculate connection uptime if we have connection establishment time
- Instant connectionTime = connectionEstablishedTime.get();
- String uptimeInfo = "";
- if (connectionTime != null) {
- Duration uptime = Duration.between(connectionTime, disconnectTime);
- uptimeInfo =
- String.format(" (connection was up for %.3f seconds)", uptime.toMillis() / 1000.0);
- }
-
- log.info(
- LogCategory.GRPC,
- "StreamCompleted",
- "Job stream onCompleted: Server closed the stream gracefully%s (total attempts: %d, successful: %d)",
- uptimeInfo,
- totalReconnectionAttempts.get(),
- successfulReconnections.get());
- }
-
- public void stop() {
- log.info(
- LogCategory.GRPC,
- "Stopping",
- "Stopping GateClientJobStream (total attempts: %d, successful: %d)",
- totalReconnectionAttempts.get(),
- successfulReconnections.get());
-
- shutdown();
- log.info(LogCategory.GRPC, "GateClientJobStreamStopped", "GateClientJobStream stopped");
- }
-
- private boolean isAdminJob(StreamJobsResponse value) {
- return value.hasDiagnostics()
- || value.hasListTenantLogs()
- || value.hasSetLogLevel()
- || value.hasShutdown()
- || value.hasInfo();
- }
-
- private void submitJobError(String jobId, String message) {
- try {
- SubmitJobResultsRequest request =
- SubmitJobResultsRequest.newBuilder()
- .setJobId(jobId)
- .setEndOfTransmission(true)
- .setErrorResult(
- SubmitJobResultsRequest.ErrorResult.newBuilder().setMessage(message).build())
- .build();
-
- GateServiceGrpc.newBlockingStub(getChannel())
- .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .withWaitForReady()
- .submitJobResults(request);
- } catch (Exception e) {
- log.error(
- LogCategory.GRPC,
- "SubmitJobErrorFailed",
- "Failed to submit error for job %s: %s",
- jobId,
- e.getMessage());
- }
- }
-
- @PreDestroy
- public void destroy() {
- log.info(
- LogCategory.GRPC,
- "GateClientJobStream@PreDestroyCalled",
- "GateClientJobStream @PreDestroy called");
- stop();
- }
-
- public Map getStreamStatus() {
- // Add timing information to status
- Instant lastDisconnect = lastDisconnectTime.get();
- Instant connectionEstablished = connectionEstablishedTime.get();
- Instant reconnectStart = reconnectionStartTime.get();
- Instant lastMessage = lastMessageTime.get();
-
- Map status = new HashMap<>();
- status.put("isRunning", isRunning.get());
- status.put("totalReconnectionAttempts", totalReconnectionAttempts.get());
- status.put("successfulReconnections", successfulReconnections.get());
- status.put("consecutiveFailures", consecutiveFailures.get());
- status.put("lastDisconnectTime", lastDisconnect != null ? lastDisconnect.toString() : null);
- status.put(
- "connectionEstablishedTime",
- connectionEstablished != null ? connectionEstablished.toString() : null);
- status.put("reconnectionStartTime", reconnectStart != null ? reconnectStart.toString() : null);
- status.put("lastErrorType", lastErrorType.get());
- status.put("lastMessageTime", lastMessage != null ? lastMessage.toString() : null);
-
- return status;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java
deleted file mode 100644
index c955370..0000000
--- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java
+++ /dev/null
@@ -1,181 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.gateclients.v2;
-
-import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc;
-import build.buf.gen.tcnapi.exile.gate.v2.PollEventsRequest;
-import com.tcn.exile.config.Config;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.plugin.PluginInterface;
-import io.grpc.StatusRuntimeException;
-import java.util.concurrent.TimeUnit;
-
-/**
- * @deprecated Use {@link GateClientEventStream} instead. This class uses the deprecated PollEvents
- * unary API which has been replaced by the EventStream bidirectional API with acknowledgment
- * support.
- */
-@Deprecated
-public class GateClientPollEvents extends GateClientAbstract {
- protected static final org.slf4j.Logger log =
- org.slf4j.LoggerFactory.getLogger(GateClientPollEvents.class);
-
- PluginInterface plugin;
-
- public GateClientPollEvents(String tenant, Config currentConfig, PluginInterface plugin) {
- super(tenant, currentConfig);
- this.plugin = plugin;
- }
-
- // Batch size for polling events - Gate supports up to 1000
- private static final int BATCH_SIZE = 100;
-
- @Override
- public void start() {
- try {
- if (isUnconfigured()) {
- log.debug("Tenant: {} - Configuration not set, skipping poll events", tenant);
- return;
- }
- if (!plugin.isRunning()) {
- log.debug(
- "Tenant: {} - Plugin is not running (possibly due to database disconnection), skipping poll events",
- tenant);
- return;
- }
-
- int eventsReceived;
- int totalProcessed = 0;
- long cycleStart = System.currentTimeMillis();
-
- // Keep polling as long as we receive a full batch (indicating more events may
- // be waiting)
- do {
- var client =
- GateServiceGrpc.newBlockingStub(getChannel())
- .withDeadlineAfter(300, TimeUnit.SECONDS)
- .withWaitForReady();
- var response =
- client.pollEvents(PollEventsRequest.newBuilder().setEventCount(BATCH_SIZE).build());
-
- eventsReceived = response.getEventsCount();
-
- if (eventsReceived == 0) {
- if (totalProcessed == 0) {
- log.debug(
- "Tenant: {} - Poll events request completed successfully but no events were received",
- tenant);
- }
- break;
- }
-
- long batchStart = System.currentTimeMillis();
- response
- .getEventsList()
- .forEach(
- event -> {
- if (event.hasAgentCall()) {
- log.debug(
- "Tenant: {} - Received agent call event {} - {}",
- tenant,
- event.getAgentCall().getCallSid(),
- event.getAgentCall().getCallType());
- plugin.handleAgentCall(event.getAgentCall());
- }
- if (event.hasAgentResponse()) {
- log.debug(
- "Tenant: {} - Received agent response event {}",
- tenant,
- event.getAgentResponse().getAgentCallResponseSid());
- plugin.handleAgentResponse(event.getAgentResponse());
- }
-
- if (event.hasTelephonyResult()) {
- log.debug(
- "Tenant: {} - Received telephony result event {} - {}",
- tenant,
- event.getTelephonyResult().getCallSid(),
- event.getTelephonyResult().getCallType());
- plugin.handleTelephonyResult(event.getTelephonyResult());
- }
-
- if (event.hasTask()) {
- log.debug(
- "Tenant: {} - Received task event {}",
- tenant,
- event.getTask().getTaskSid());
- plugin.handleTask(event.getTask());
- }
-
- if (event.hasTransferInstance()) {
- log.debug(
- "Tenant: {} - Received transfer instance event {}",
- tenant,
- event.getTransferInstance().getTransferInstanceId());
- plugin.handleTransferInstance(event.getTransferInstance());
- }
-
- if (event.hasCallRecording()) {
- log.debug(
- "Tenant: {} - Received call recording event {}",
- tenant,
- event.getCallRecording().getRecordingId());
- plugin.handleCallRecording(event.getCallRecording());
- }
- });
-
- long batchEnd = System.currentTimeMillis();
- totalProcessed += eventsReceived;
-
- // Warn if individual batch processing is slow
- if (eventsReceived > 0) {
- long avg = (batchEnd - batchStart) / eventsReceived;
- if (avg > 1000) {
- log.warn(
- "Tenant: {} - Poll events batch completed {} events in {}ms, average time per event: {}ms, this is long",
- tenant,
- eventsReceived,
- batchEnd - batchStart,
- avg);
- }
- }
-
- } while (eventsReceived >= BATCH_SIZE);
-
- // Log summary if we processed events across multiple batches
- if (totalProcessed > 0) {
- long cycleEnd = System.currentTimeMillis();
- log.info(
- "Tenant: {} - Poll cycle completed, processed {} total events in {}ms",
- tenant,
- totalProcessed,
- cycleEnd - cycleStart);
- }
-
- } catch (StatusRuntimeException e) {
- if (handleStatusRuntimeException(e)) {
- // Already handled in parent class method
- } else {
- log.error("Tenant: {} - Error in poll events: {}", tenant, e.getMessage());
- }
- } catch (UnconfiguredException e) {
- log.error("Tenant: {} - Error while getting client configuration {}", tenant, e.getMessage());
- } catch (Exception e) {
- log.error("Tenant: {} - Unexpected error in poll events", tenant, e);
- }
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/handler/EventHandler.java b/core/src/main/java/com/tcn/exile/handler/EventHandler.java
new file mode 100644
index 0000000..745bd73
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/handler/EventHandler.java
@@ -0,0 +1,28 @@
+package com.tcn.exile.handler;
+
+import com.tcn.exile.model.event.*;
+
+/**
+ * Handles events dispatched by the gate server. Events are informational — the server only needs an
+ * acknowledgment, not a result.
+ *
+ * If a method throws, the work item is nacked and will be redelivered.
+ *
+ *
Methods run on virtual threads — blocking I/O is fine.
+ *
+ *
Default implementations are no-ops. Override only what you need.
+ */
+public interface EventHandler {
+
+ default void onAgentCall(AgentCallEvent event) throws Exception {}
+
+ default void onTelephonyResult(TelephonyResultEvent event) throws Exception {}
+
+ default void onAgentResponse(AgentResponseEvent event) throws Exception {}
+
+ default void onTransferInstance(TransferInstanceEvent event) throws Exception {}
+
+ default void onCallRecording(CallRecordingEvent event) throws Exception {}
+
+ default void onTask(TaskEvent event) throws Exception {}
+}
diff --git a/core/src/main/java/com/tcn/exile/handler/JobHandler.java b/core/src/main/java/com/tcn/exile/handler/JobHandler.java
new file mode 100644
index 0000000..dd34eb4
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/handler/JobHandler.java
@@ -0,0 +1,95 @@
+package com.tcn.exile.handler;
+
+import com.tcn.exile.model.*;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Handles jobs dispatched by the gate server. Each method receives plain Java parameters and
+ * returns plain Java types. Proto conversion is handled internally by the library.
+ *
+ *
Methods run on virtual threads — blocking I/O (JDBC, HTTP) is fine.
+ *
+ *
Default implementations throw {@link UnsupportedOperationException}. Integrations override
+ * only the jobs they handle and declare those as capabilities at registration.
+ */
+public interface JobHandler {
+
+ default List listPools() throws Exception {
+ throw new UnsupportedOperationException("listPools not implemented");
+ }
+
+ default Pool getPoolStatus(String poolId) throws Exception {
+ throw new UnsupportedOperationException("getPoolStatus not implemented");
+ }
+
+ default Page getPoolRecords(String poolId, String pageToken, int pageSize)
+ throws Exception {
+ throw new UnsupportedOperationException("getPoolRecords not implemented");
+ }
+
+ default Page searchRecords(List filters, String pageToken, int pageSize)
+ throws Exception {
+ throw new UnsupportedOperationException("searchRecords not implemented");
+ }
+
+ default List getRecordFields(String poolId, String recordId, List fieldNames)
+ throws Exception {
+ throw new UnsupportedOperationException("getRecordFields not implemented");
+ }
+
+ default boolean setRecordFields(String poolId, String recordId, List fields)
+ throws Exception {
+ throw new UnsupportedOperationException("setRecordFields not implemented");
+ }
+
+ default String createPayment(String poolId, String recordId, Map paymentData)
+ throws Exception {
+ throw new UnsupportedOperationException("createPayment not implemented");
+ }
+
+ default DataRecord popAccount(String poolId, String recordId) throws Exception {
+ throw new UnsupportedOperationException("popAccount not implemented");
+ }
+
+ default Map executeLogic(String logicName, Map parameters)
+ throws Exception {
+ throw new UnsupportedOperationException("executeLogic not implemented");
+ }
+
+ /** Return client info. Keys: appName, appVersion, plus any custom metadata. */
+ default Map info() throws Exception {
+ throw new UnsupportedOperationException("info not implemented");
+ }
+
+ default void shutdown(String reason) throws Exception {
+ throw new UnsupportedOperationException("shutdown not implemented");
+ }
+
+ default void processLog(String payload) throws Exception {
+ throw new UnsupportedOperationException("processLog not implemented");
+ }
+
+ /** Return system diagnostics as structured sections. */
+ default DiagnosticsInfo diagnostics() throws Exception {
+ throw new UnsupportedOperationException("diagnostics not implemented");
+ }
+
+ default Page listTenantLogs(
+ Instant startTime, Instant endTime, String pageToken, int pageSize) throws Exception {
+ throw new UnsupportedOperationException("listTenantLogs not implemented");
+ }
+
+ default void setLogLevel(String loggerName, String level) throws Exception {
+ throw new UnsupportedOperationException("setLogLevel not implemented");
+ }
+
+ record DiagnosticsInfo(
+ Map systemInfo,
+ Map runtimeInfo,
+ Map databaseInfo,
+ Map custom) {}
+
+ record LogEntry(Instant timestamp, String level, String logger, String message) {}
+}
diff --git a/core/src/main/java/com/tcn/exile/handler/Plugin.java b/core/src/main/java/com/tcn/exile/handler/Plugin.java
new file mode 100644
index 0000000..0718cb7
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/handler/Plugin.java
@@ -0,0 +1,38 @@
+package com.tcn.exile.handler;
+
+import com.tcn.exile.service.ConfigService;
+
+/**
+ * The integration point for CRM plugins. Implementations provide job handling, event handling, and
+ * config validation.
+ *
+ * Extend {@link PluginBase} for default implementations of common operations (logs, diagnostics,
+ * info, shutdown, log level control). Only override the CRM-specific methods you need.
+ *
+ *
Lifecycle:
+ *
+ *
+ * Config is polled from the gate
+ * {@link #onConfig} is called — plugin validates and initializes resources
+ * If {@code onConfig} returns {@code true}, the WorkStream opens
+ * Jobs arrive → {@link JobHandler} methods are called
+ * Events arrive → {@link EventHandler} methods are called
+ *
+ */
+public interface Plugin extends JobHandler, EventHandler {
+
+ /**
+ * Called when the gate returns a new or changed config. The plugin should validate the config
+ * payload and initialize its resources (database connections, HTTP clients, etc.).
+ *
+ * Return {@code true} if the plugin is ready to handle work. The WorkStream opens only after
+ * the first {@code true} return. Return {@code false} to reject the config — the poller will
+ * retry on the next cycle.
+ */
+ boolean onConfig(ConfigService.ClientConfiguration config);
+
+ /** Human-readable plugin name for diagnostics. */
+ default String pluginName() {
+ return getClass().getSimpleName();
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/handler/PluginBase.java b/core/src/main/java/com/tcn/exile/handler/PluginBase.java
new file mode 100644
index 0000000..891063e
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java
@@ -0,0 +1,215 @@
+package com.tcn.exile.handler;
+
+import com.tcn.exile.memlogger.MemoryAppender;
+import com.tcn.exile.memlogger.MemoryAppenderInstance;
+import com.tcn.exile.model.Page;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Base class for plugins that provides default implementations for common operations: log
+ * retrieval, log level control, diagnostics, info, shutdown, and remote logging.
+ *
+ *
Extend this class and override only the CRM-specific methods (pool/record operations, event
+ * handlers, config validation).
+ *
+ *
Usage:
+ *
+ *
{@code
+ * public class FinviPlugin extends PluginBase {
+ * private HikariDataSource dataSource;
+ *
+ * @Override
+ * public boolean onConfig(ClientConfiguration config) {
+ * dataSource = initDataSource(config.configPayload());
+ * return dataSource != null;
+ * }
+ *
+ * @Override
+ * public List listPools() {
+ * return db.query("SELECT ...");
+ * }
+ * }
+ * }
+ */
+public abstract class PluginBase implements Plugin {
+
+ private static final Logger log = LoggerFactory.getLogger(PluginBase.class);
+
+ @Override
+ public String pluginName() {
+ return getClass().getSimpleName();
+ }
+
+ // --- Logs ---
+
+ @Override
+ public Page listTenantLogs(
+ Instant startTime, Instant endTime, String pageToken, int pageSize) throws Exception {
+ MemoryAppender appender = MemoryAppenderInstance.getInstance();
+ if (appender == null) {
+ return new Page<>(List.of(), "");
+ }
+
+ long startMs = startTime != null ? startTime.toEpochMilli() : 0;
+ long endMs = endTime != null ? endTime.toEpochMilli() : System.currentTimeMillis();
+
+ var logEvents = appender.getEventsWithTimestamps();
+ log.info(
+ "listTenantLogs: appender has {} events, range {}..{}, filtering",
+ logEvents.size(),
+ startMs,
+ endMs);
+ var entries =
+ logEvents.stream()
+ .filter(e -> e.timestamp >= startMs && e.timestamp <= endMs)
+ .map(
+ e ->
+ new JobHandler.LogEntry(
+ Instant.ofEpochMilli(e.timestamp), "INFO", "memlogger", e.message))
+ .limit(pageSize > 0 ? pageSize : 100)
+ .toList();
+ log.info("listTenantLogs: returning {} entries", entries.size());
+
+ return new Page<>(entries, "");
+ }
+
+ @Override
+ public void setLogLevel(String loggerName, String level) throws Exception {
+ var loggerContext =
+ (ch.qos.logback.classic.LoggerContext) org.slf4j.LoggerFactory.getILoggerFactory();
+ var logger = loggerContext.getLogger(loggerName);
+ if (logger != null) {
+ var newLevel = ch.qos.logback.classic.Level.valueOf(level);
+ logger.setLevel(newLevel);
+ log.info("Log level changed: {}={}", loggerName, newLevel);
+ }
+ }
+
+ @Override
+ public void processLog(String payload) throws Exception {
+ log.info("Remote log: {}", payload);
+ }
+
+ // --- Info & Diagnostics ---
+
+ @Override
+ public Map info() throws Exception {
+ return Map.of(
+ "appName", pluginName(),
+ "runtime", System.getProperty("java.version"),
+ "os", System.getProperty("os.name"));
+ }
+
+ @Override
+ public JobHandler.DiagnosticsInfo diagnostics() throws Exception {
+ var rt = Runtime.getRuntime();
+ var mem = java.lang.management.ManagementFactory.getMemoryMXBean();
+ var heap = mem.getHeapMemoryUsage();
+ var nonHeap = mem.getNonHeapMemoryUsage();
+ var os = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
+ var thread = java.lang.management.ManagementFactory.getThreadMXBean();
+
+ // System info.
+ var systemInfo = new java.util.LinkedHashMap();
+ systemInfo.put("os.name", System.getProperty("os.name"));
+ systemInfo.put("os.version", System.getProperty("os.version"));
+ systemInfo.put("os.arch", System.getProperty("os.arch"));
+ systemInfo.put("processors", rt.availableProcessors());
+ systemInfo.put("system.load.average", os.getSystemLoadAverage());
+ systemInfo.put("hostname", getHostname());
+
+ // Detect container environment.
+ var containerFile = new java.io.File("/.dockerenv");
+ if (containerFile.exists()) {
+ systemInfo.put("container", "docker");
+ }
+ var cgroupFile = new java.io.File("/proc/1/cgroup");
+ if (cgroupFile.exists()) {
+ systemInfo.put("cgroup", true);
+ }
+ var podName = System.getenv("HOSTNAME");
+ if (podName != null) {
+ systemInfo.put("pod.name", podName);
+ }
+
+ // Storage.
+ for (var root : java.io.File.listRoots()) {
+ systemInfo.put(
+ "storage." + root.getAbsolutePath().replace("/", "root"),
+ Map.of(
+ "total", root.getTotalSpace(),
+ "free", root.getFreeSpace(),
+ "usable", root.getUsableSpace()));
+ }
+
+ // Runtime info.
+ var runtimeInfo = new java.util.LinkedHashMap();
+ runtimeInfo.put("java.version", System.getProperty("java.version"));
+ runtimeInfo.put("java.vendor", System.getProperty("java.vendor"));
+ runtimeInfo.put("java.vm.name", System.getProperty("java.vm.name"));
+ runtimeInfo.put("java.vm.version", System.getProperty("java.vm.version"));
+ runtimeInfo.put("heap.init", heap.getInit());
+ runtimeInfo.put("heap.used", heap.getUsed());
+ runtimeInfo.put("heap.committed", heap.getCommitted());
+ runtimeInfo.put("heap.max", heap.getMax());
+ runtimeInfo.put("non_heap.used", nonHeap.getUsed());
+ runtimeInfo.put("non_heap.committed", nonHeap.getCommitted());
+ runtimeInfo.put("threads.live", thread.getThreadCount());
+ runtimeInfo.put("threads.daemon", thread.getDaemonThreadCount());
+ runtimeInfo.put("threads.peak", thread.getPeakThreadCount());
+ runtimeInfo.put("threads.total_started", thread.getTotalStartedThreadCount());
+ runtimeInfo.put(
+ "uptime.ms", java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime());
+
+ // GC info.
+ for (var gc : java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()) {
+ runtimeInfo.put("gc." + gc.getName() + ".count", gc.getCollectionCount());
+ runtimeInfo.put("gc." + gc.getName() + ".time_ms", gc.getCollectionTime());
+ }
+
+ // Database info — empty by default, plugins override to add connection pool stats.
+ var databaseInfo = new java.util.LinkedHashMap();
+
+ // Custom — plugin name and memory appender stats.
+ var custom = new java.util.LinkedHashMap();
+ custom.put("plugin.name", pluginName());
+ var appender = MemoryAppenderInstance.getInstance();
+ if (appender != null) {
+ custom.put("memlogger.events", appender.getEventsWithTimestamps().size());
+ }
+
+ return new JobHandler.DiagnosticsInfo(systemInfo, runtimeInfo, databaseInfo, custom);
+ }
+
+ private static String getHostname() {
+ try {
+ return java.net.InetAddress.getLocalHost().getHostName();
+ } catch (Exception e) {
+ var hostname = System.getenv("HOSTNAME");
+ return hostname != null ? hostname : "unknown";
+ }
+ }
+
+ // --- Shutdown ---
+
+ @Override
+ public void shutdown(String reason) throws Exception {
+ log.warn("Shutdown requested: {}. Exiting in 2 seconds.", reason);
+ Thread.ofVirtual()
+ .name("exile-shutdown")
+ .start(
+ () -> {
+ try {
+ Thread.sleep(2000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ log.info("Exiting now (reason: {})", reason);
+ System.exit(0);
+ });
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/Backoff.java b/core/src/main/java/com/tcn/exile/internal/Backoff.java
new file mode 100644
index 0000000..17b9dc1
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/Backoff.java
@@ -0,0 +1,41 @@
+package com.tcn.exile.internal;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/** Exponential backoff with jitter for reconnection. */
+public final class Backoff {
+
+ private static final long BASE_MS = 500;
+ private static final long MAX_MS = 10_000;
+ private static final double JITTER = 0.2;
+
+ private int failures;
+
+ public Backoff() {
+ this.failures = 0;
+ }
+
+ public void reset() {
+ failures = 0;
+ }
+
+ public void recordFailure() {
+ failures++;
+ }
+
+ /** Compute the next backoff delay in milliseconds. Returns 0 on first attempt. */
+ public long nextDelayMs() {
+ if (failures <= 0) return 0;
+ long delay = BASE_MS * (1L << Math.min(failures - 1, 10));
+ double jitter = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * JITTER;
+ return Math.min((long) (delay * jitter), MAX_MS);
+ }
+
+ /** Sleep for the computed backoff delay. */
+ public void sleep() throws InterruptedException {
+ long ms = nextDelayMs();
+ if (ms > 0) {
+ Thread.sleep(ms);
+ }
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java
new file mode 100644
index 0000000..94f37d4
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java
@@ -0,0 +1,99 @@
+package com.tcn.exile.internal;
+
+import com.tcn.exile.ExileConfig;
+import io.grpc.ManagedChannel;
+import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
+import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
+import java.io.ByteArrayInputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.spec.RSAPrivateCrtKeySpec;
+import java.util.Base64;
+import java.util.concurrent.TimeUnit;
+
+/** Creates mTLS gRPC channels from {@link ExileConfig}. */
+public final class ChannelFactory {
+
+ private ChannelFactory() {}
+
+ /** Create a new mTLS channel to the gate server. Caller owns the channel lifecycle. */
+ public static ManagedChannel create(ExileConfig config) {
+ try {
+ // Handle PKCS#1 (BEGIN RSA PRIVATE KEY) → PKCS#8 (BEGIN PRIVATE KEY) conversion.
+ var keyPem = config.privateKey();
+ if (keyPem.contains("BEGIN RSA PRIVATE KEY")) {
+ keyPem = convertPkcs1ToPkcs8Pem(keyPem);
+ }
+
+ var sslContext =
+ GrpcSslContexts.forClient()
+ .trustManager(
+ new ByteArrayInputStream(config.rootCert().getBytes(StandardCharsets.UTF_8)))
+ .keyManager(
+ new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)),
+ new ByteArrayInputStream(keyPem.getBytes(StandardCharsets.UTF_8)))
+ .build();
+
+ // Use InetSocketAddress to avoid the unix domain socket name resolver on macOS.
+ return NettyChannelBuilder.forAddress(
+ new InetSocketAddress(config.apiHostname(), config.apiPort()))
+ .sslContext(sslContext)
+ .keepAliveTime(10, TimeUnit.SECONDS)
+ .keepAliveTimeout(5, TimeUnit.SECONDS)
+ .keepAliveWithoutCalls(true)
+ .idleTimeout(30, TimeUnit.MINUTES)
+ .flowControlWindow(4 * 1024 * 1024) // 4MB — match envoy upstream window
+ .build();
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to create gRPC channel", e);
+ }
+ }
+
+ /** Gracefully shut down a channel, forcing after timeout. */
+ public static void shutdown(ManagedChannel channel) {
+ if (channel == null) return;
+ channel.shutdown();
+ try {
+ if (!channel.awaitTermination(10, TimeUnit.SECONDS)) {
+ channel.shutdownNow();
+ channel.awaitTermination(5, TimeUnit.SECONDS);
+ }
+ } catch (InterruptedException e) {
+ channel.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Converts a PKCS#1 RSA private key PEM to PKCS#8 PEM format. Netty's SSL only accepts PKCS#8
+ * (BEGIN PRIVATE KEY), but exile certificates use PKCS#1 (BEGIN RSA PRIVATE KEY).
+ */
+ private static String convertPkcs1ToPkcs8Pem(String pkcs1Pem) throws Exception {
+ var base64 =
+ pkcs1Pem
+ .replace("-----BEGIN RSA PRIVATE KEY-----", "")
+ .replace("-----END RSA PRIVATE KEY-----", "")
+ .replaceAll("\\s", "");
+ var pkcs1Bytes = Base64.getDecoder().decode(base64);
+
+ var keyFactory = KeyFactory.getInstance("RSA");
+ var rsaKey = org.bouncycastle.asn1.pkcs.RSAPrivateKey.getInstance(pkcs1Bytes);
+ var spec =
+ new RSAPrivateCrtKeySpec(
+ rsaKey.getModulus(),
+ rsaKey.getPublicExponent(),
+ rsaKey.getPrivateExponent(),
+ rsaKey.getPrime1(),
+ rsaKey.getPrime2(),
+ rsaKey.getExponent1(),
+ rsaKey.getExponent2(),
+ rsaKey.getCoefficient());
+ PrivateKey privateKey = keyFactory.generatePrivate(spec);
+ var pkcs8Bytes = privateKey.getEncoded();
+
+ var pkcs8Base64 = Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(pkcs8Bytes);
+ return "-----BEGIN PRIVATE KEY-----\n" + pkcs8Base64 + "\n-----END PRIVATE KEY-----\n";
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java
new file mode 100644
index 0000000..e8c1c0d
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java
@@ -0,0 +1,147 @@
+package com.tcn.exile.internal;
+
+import build.buf.gen.tcnapi.exile.gate.v3.LogLevel;
+import build.buf.gen.tcnapi.exile.gate.v3.LogRecord;
+import com.google.protobuf.Timestamp;
+import com.tcn.exile.memlogger.LogShipper;
+import com.tcn.exile.memlogger.MemoryAppender;
+import com.tcn.exile.service.TelemetryService;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * LogShipper implementation that sends structured log records to the gate via TelemetryService. All
+ * exceptions are caught — telemetry must never break the application.
+ */
+public final class GrpcLogShipper implements LogShipper {
+
+ private static final Logger log = LoggerFactory.getLogger(GrpcLogShipper.class);
+
+ private final TelemetryService telemetryService;
+ private final String clientId;
+
+ public GrpcLogShipper(TelemetryService telemetryService, String clientId) {
+ this.telemetryService = telemetryService;
+ this.clientId = clientId;
+ }
+
+ @Override
+ public void shipLogs(List payload) {
+ // Legacy string-only path: wrap as INFO-level records.
+ var records = new ArrayList();
+ var now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build();
+ for (var msg : payload) {
+ records.add(
+ LogRecord.newBuilder()
+ .setTime(now)
+ .setLevel(LogLevel.LOG_LEVEL_INFO)
+ .setMessage(msg)
+ .build());
+ }
+ sendRecords(records);
+ }
+
+ @Override
+ public void shipStructuredLogs(List events) {
+ var records = new ArrayList();
+ for (var event : events) {
+ var builder =
+ LogRecord.newBuilder()
+ .setTime(
+ Timestamp.newBuilder()
+ .setSeconds(event.timestamp / 1000)
+ .setNanos((int) ((event.timestamp % 1000) * 1_000_000)))
+ .setLevel(mapLevel(event.level))
+ .setMessage(toJson(event));
+
+ if (event.loggerName != null) builder.setLoggerName(event.loggerName);
+ if (event.threadName != null) builder.setThreadName(event.threadName);
+ if (event.stackTrace != null) builder.setStackTrace(event.stackTrace);
+ if (event.mdc != null) builder.putAllMdc(event.mdc);
+ if (event.traceId != null) builder.setTraceId(event.traceId);
+ if (event.spanId != null) builder.setSpanId(event.spanId);
+
+ records.add(builder.build());
+ }
+ sendRecords(records);
+ }
+
+ /** Serialize the log event as a JSON string for structured gate-side processing. */
+ private static String toJson(MemoryAppender.LogEvent event) {
+ var map = new LinkedHashMap();
+ map.put(
+ "timestamp",
+ DateTimeFormatter.ISO_INSTANT.format(
+ Instant.ofEpochMilli(event.timestamp).atOffset(ZoneOffset.UTC)));
+ if (event.level != null) map.put("level", event.level);
+ if (event.loggerName != null) map.put("logger", event.loggerName);
+ if (event.message != null) map.put("message", event.message);
+ if (event.threadName != null) map.put("thread", event.threadName);
+ if (event.mdc != null && !event.mdc.isEmpty()) map.put("mdc", event.mdc);
+ if (event.stackTrace != null) map.put("stackTrace", event.stackTrace);
+ if (event.traceId != null) map.put("traceId", event.traceId);
+ if (event.spanId != null) map.put("spanId", event.spanId);
+ return toJsonString(map);
+ }
+
+ /** Minimal JSON serializer — no external dependency needed for simple maps. */
+ @SuppressWarnings("unchecked")
+ private static String toJsonString(Object obj) {
+ if (obj == null) return "null";
+ if (obj instanceof String s) return "\"" + escapeJson(s) + "\"";
+ if (obj instanceof Number n) return n.toString();
+ if (obj instanceof Boolean b) return b.toString();
+ if (obj instanceof java.util.Map, ?> m) {
+ var sb = new StringBuilder("{");
+ boolean first = true;
+ for (var entry : m.entrySet()) {
+ if (!first) sb.append(",");
+ sb.append("\"").append(escapeJson(entry.getKey().toString())).append("\":");
+ sb.append(toJsonString(entry.getValue()));
+ first = false;
+ }
+ return sb.append("}").toString();
+ }
+ return "\"" + escapeJson(obj.toString()) + "\"";
+ }
+
+ private static String escapeJson(String s) {
+ return s.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+
+ private void sendRecords(List records) {
+ try {
+ int accepted = telemetryService.reportLogs(clientId, records);
+ log.debug("Shipped {} log records ({} accepted)", records.size(), accepted);
+ } catch (Exception e) {
+ log.debug("Failed to ship logs: {}", e.getMessage());
+ }
+ }
+
+ private static LogLevel mapLevel(String level) {
+ if (level == null) return LogLevel.LOG_LEVEL_INFO;
+ return switch (level.toUpperCase()) {
+ case "TRACE" -> LogLevel.LOG_LEVEL_TRACE;
+ case "DEBUG" -> LogLevel.LOG_LEVEL_DEBUG;
+ case "INFO" -> LogLevel.LOG_LEVEL_INFO;
+ case "WARN" -> LogLevel.LOG_LEVEL_WARN;
+ case "ERROR" -> LogLevel.LOG_LEVEL_ERROR;
+ default -> LogLevel.LOG_LEVEL_INFO;
+ };
+ }
+
+ @Override
+ public void stop() {
+ // No-op — channel lifecycle is managed by ExileClient.
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java b/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java
new file mode 100644
index 0000000..6264fcc
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java
@@ -0,0 +1,55 @@
+package com.tcn.exile.internal;
+
+import com.tcn.exile.service.TelemetryService;
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import io.opentelemetry.sdk.metrics.InstrumentType;
+import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
+import io.opentelemetry.sdk.metrics.data.MetricData;
+import io.opentelemetry.sdk.metrics.export.MetricExporter;
+import java.util.Collection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * OTel MetricExporter that sends metric data to the gate via the TelemetryService gRPC endpoint.
+ * All exceptions are caught and logged — telemetry must never break the application.
+ */
+public final class GrpcMetricExporter implements MetricExporter {
+
+ private static final Logger log = LoggerFactory.getLogger(GrpcMetricExporter.class);
+
+ private final TelemetryService telemetryService;
+ private final String clientId;
+
+ public GrpcMetricExporter(TelemetryService telemetryService, String clientId) {
+ this.telemetryService = telemetryService;
+ this.clientId = clientId;
+ }
+
+ @Override
+ public CompletableResultCode export(Collection metrics) {
+ try {
+ int accepted = telemetryService.reportMetrics(clientId, metrics);
+ log.debug("Exported {} metric data points ({} accepted)", metrics.size(), accepted);
+ return CompletableResultCode.ofSuccess();
+ } catch (Exception e) {
+ log.debug("Failed to export metrics: {}", e.getMessage());
+ return CompletableResultCode.ofFailure();
+ }
+ }
+
+ @Override
+ public CompletableResultCode flush() {
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public CompletableResultCode shutdown() {
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) {
+ return AggregationTemporality.CUMULATIVE;
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java
new file mode 100644
index 0000000..1848a0f
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java
@@ -0,0 +1,207 @@
+package com.tcn.exile.internal;
+
+import com.tcn.exile.StreamStatus;
+import com.tcn.exile.service.TelemetryService;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.DoubleHistogram;
+import io.opentelemetry.api.metrics.LongCounter;
+import io.opentelemetry.api.metrics.Meter;
+import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.metrics.SdkMeterProvider;
+import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import java.lang.management.ManagementFactory;
+import java.time.Duration;
+import java.util.function.Supplier;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Sets up OTel SDK metric collection with built-in exile instruments and a custom gRPC exporter.
+ * Exposes the {@link Meter} so plugin developers can register their own instruments.
+ */
+public final class MetricsManager implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(MetricsManager.class);
+
+ private final OpenTelemetrySdk openTelemetry;
+ private final SdkMeterProvider meterProvider;
+ private final Meter meter;
+ private final DoubleHistogram workDuration;
+ private final DoubleHistogram methodDuration;
+ private final LongCounter methodCalls;
+ private final DoubleHistogram reconnectDuration;
+
+ private static final AttributeKey METHOD_KEY = AttributeKey.stringKey("method");
+ private static final AttributeKey STATUS_KEY = AttributeKey.stringKey("status");
+
+ /**
+ * @param telemetryService gRPC stub for reporting metrics
+ * @param clientId unique client identifier
+ * @param orgId organization ID (from config poll)
+ * @param certificateName certificate name from the config file
+ * @param statusSupplier supplies current WorkStream status snapshot
+ */
+ public MetricsManager(
+ TelemetryService telemetryService,
+ String clientId,
+ String orgId,
+ String certificateName,
+ Supplier statusSupplier) {
+
+ var exporter = new GrpcMetricExporter(telemetryService, clientId);
+ var reader = PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build();
+
+ var resource =
+ Resource.getDefault()
+ .merge(
+ Resource.create(
+ Attributes.of(
+ AttributeKey.stringKey("exile.org_id"), orgId,
+ AttributeKey.stringKey("exile.certificate_name"), certificateName,
+ AttributeKey.stringKey("exile.client_id"), clientId)));
+
+ this.meterProvider =
+ SdkMeterProvider.builder().setResource(resource).registerMetricReader(reader).build();
+
+ // TracerProvider generates valid trace/span IDs for log correlation.
+ var tracerProvider = SdkTracerProvider.builder().setResource(resource).build();
+
+ var sdkBuilder =
+ OpenTelemetrySdk.builder()
+ .setMeterProvider(meterProvider)
+ .setTracerProvider(tracerProvider);
+
+ OpenTelemetrySdk sdk;
+ try {
+ sdk = sdkBuilder.buildAndRegisterGlobal();
+ } catch (IllegalStateException e) {
+ // Already registered (e.g. multi-tenant or restart) — build without registering.
+ sdk = sdkBuilder.build();
+ }
+ this.openTelemetry = sdk;
+
+ this.meter = meterProvider.get("com.tcn.exile.sati");
+
+ // --- Built-in instruments ---
+
+ // WorkStream counters (cumulative, read from StreamStatus)
+ meter
+ .counterBuilder("exile.work.completed")
+ .setDescription("Total work items completed since start")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(statusSupplier.get().completedTotal()));
+
+ meter
+ .counterBuilder("exile.work.failed")
+ .setDescription("Total work items that failed since start")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(statusSupplier.get().failedTotal()));
+
+ meter
+ .counterBuilder("exile.work.reconnects")
+ .setDescription("Total stream reconnection attempts since start")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(statusSupplier.get().reconnectAttempts()));
+
+ // WorkStream gauges
+ meter
+ .gaugeBuilder("exile.work.inflight")
+ .ofLongs()
+ .setDescription("Work items currently being processed")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(statusSupplier.get().inflight()));
+
+ meter
+ .gaugeBuilder("exile.work.phase")
+ .ofLongs()
+ .setDescription(
+ "WorkStream phase (0=IDLE, 1=CONNECTING, 2=REGISTERING, 3=ACTIVE, 4=RECONNECTING, 5=CLOSED)")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(statusSupplier.get().phase().ordinal()));
+
+ // JVM gauges
+ var memoryBean = ManagementFactory.getMemoryMXBean();
+ var threadBean = ManagementFactory.getThreadMXBean();
+
+ meter
+ .gaugeBuilder("exile.jvm.heap_used")
+ .ofLongs()
+ .setDescription("JVM heap memory used")
+ .setUnit("bytes")
+ .buildWithCallback(obs -> obs.record(memoryBean.getHeapMemoryUsage().getUsed()));
+
+ meter
+ .gaugeBuilder("exile.jvm.threads")
+ .ofLongs()
+ .setDescription("JVM thread count")
+ .setUnit("1")
+ .buildWithCallback(obs -> obs.record(threadBean.getThreadCount()));
+
+ // Work duration histogram (recorded externally via recordWorkDuration)
+ this.workDuration =
+ meter
+ .histogramBuilder("exile.work.duration")
+ .setDescription("Time to process a work item (job or event)")
+ .setUnit("s")
+ .build();
+
+ // Per-method metrics (method name as attribute)
+ this.methodDuration =
+ meter
+ .histogramBuilder("exile.plugin.duration")
+ .setDescription("Time to execute a plugin method")
+ .setUnit("s")
+ .build();
+
+ this.methodCalls =
+ meter
+ .counterBuilder("exile.plugin.calls")
+ .setDescription("Plugin method invocations")
+ .setUnit("1")
+ .build();
+
+ this.reconnectDuration =
+ meter
+ .histogramBuilder("exile.work.reconnect_duration")
+ .setDescription("Time from stream disconnect to successful re-registration")
+ .setUnit("s")
+ .build();
+
+ log.info(
+ "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, certificateName={})",
+ clientId,
+ orgId,
+ certificateName);
+ }
+
+ /** The OTel Meter for plugin developers to create custom instruments. */
+ public Meter meter() {
+ return meter;
+ }
+
+ /** Record the duration of a completed work item. Called from WorkStreamClient. */
+ public void recordWorkDuration(double seconds) {
+ workDuration.record(seconds);
+ }
+
+ /** Record the time from disconnect to successful re-registration. */
+ public void recordReconnectDuration(double seconds) {
+ reconnectDuration.record(seconds);
+ }
+
+ /** Record a plugin method call with duration and success/failure status. */
+ public void recordMethodCall(String method, double durationSeconds, boolean success) {
+ var attrs = Attributes.of(METHOD_KEY, method, STATUS_KEY, success ? "ok" : "error");
+ methodCalls.add(1, attrs);
+ methodDuration.record(durationSeconds, attrs);
+ }
+
+ @Override
+ public void close() {
+ openTelemetry.close();
+ log.info("MetricsManager shut down");
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java
new file mode 100644
index 0000000..05cf911
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java
@@ -0,0 +1,395 @@
+package com.tcn.exile.internal;
+
+import com.tcn.exile.model.*;
+import com.tcn.exile.model.DataRecord;
+import com.tcn.exile.model.event.*;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Converts between proto types and the public Java model. This is the only place in the library
+ * that touches generated proto classes directly.
+ */
+public final class ProtoConverter {
+
+ private ProtoConverter() {}
+
+ // ---- Duration / Timestamp ----
+
+ public static Duration toDuration(com.google.protobuf.Duration d) {
+ if (d == null || d.equals(com.google.protobuf.Duration.getDefaultInstance()))
+ return Duration.ZERO;
+ return Duration.ofSeconds(d.getSeconds(), d.getNanos());
+ }
+
+ public static com.google.protobuf.Duration fromDuration(Duration d) {
+ if (d == null) return com.google.protobuf.Duration.getDefaultInstance();
+ return com.google.protobuf.Duration.newBuilder()
+ .setSeconds(d.getSeconds())
+ .setNanos(d.getNano())
+ .build();
+ }
+
+ public static Instant toInstant(com.google.protobuf.Timestamp t) {
+ if (t == null || t.equals(com.google.protobuf.Timestamp.getDefaultInstance())) return null;
+ return Instant.ofEpochSecond(t.getSeconds(), t.getNanos());
+ }
+
+ public static com.google.protobuf.Timestamp fromInstant(Instant i) {
+ if (i == null) return com.google.protobuf.Timestamp.getDefaultInstance();
+ return com.google.protobuf.Timestamp.newBuilder()
+ .setSeconds(i.getEpochSecond())
+ .setNanos(i.getNano())
+ .build();
+ }
+
+ // ---- Enums ----
+
+ public static CallType toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType ct) {
+ return switch (ct) {
+ case CALL_TYPE_INBOUND -> CallType.INBOUND;
+ case CALL_TYPE_OUTBOUND -> CallType.OUTBOUND;
+ case CALL_TYPE_PREVIEW -> CallType.PREVIEW;
+ case CALL_TYPE_MANUAL -> CallType.MANUAL;
+ case CALL_TYPE_MAC -> CallType.MAC;
+ default -> CallType.UNSPECIFIED;
+ };
+ }
+
+ public static AgentState toAgentState(build.buf.gen.tcnapi.exile.gate.v3.AgentState as) {
+ try {
+ return AgentState.valueOf(as.name().replace("AGENT_STATE_", ""));
+ } catch (IllegalArgumentException e) {
+ return AgentState.UNSPECIFIED;
+ }
+ }
+
+ public static build.buf.gen.tcnapi.exile.gate.v3.AgentState fromAgentState(AgentState as) {
+ try {
+ return build.buf.gen.tcnapi.exile.gate.v3.AgentState.valueOf("AGENT_STATE_" + as.name());
+ } catch (IllegalArgumentException e) {
+ return build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED;
+ }
+ }
+
+ // ---- Core types ----
+
+ public static Pool toPool(build.buf.gen.tcnapi.exile.gate.v3.Pool p) {
+ return new Pool(
+ p.getPoolId(),
+ p.getDescription(),
+ switch (p.getStatus()) {
+ case POOL_STATUS_READY -> Pool.PoolStatus.READY;
+ case POOL_STATUS_NOT_READY -> Pool.PoolStatus.NOT_READY;
+ case POOL_STATUS_BUSY -> Pool.PoolStatus.BUSY;
+ default -> Pool.PoolStatus.UNSPECIFIED;
+ },
+ p.getRecordCount());
+ }
+
+ public static build.buf.gen.tcnapi.exile.gate.v3.Pool fromPool(Pool p) {
+ return build.buf.gen.tcnapi.exile.gate.v3.Pool.newBuilder()
+ .setPoolId(p.poolId())
+ .setDescription(p.description())
+ .setStatus(
+ switch (p.status()) {
+ case READY -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_READY;
+ case NOT_READY ->
+ build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY;
+ case BUSY -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_BUSY;
+ default -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED;
+ })
+ .setRecordCount(p.recordCount())
+ .build();
+ }
+
+ public static DataRecord toRecord(build.buf.gen.tcnapi.exile.gate.v3.Record r) {
+ return new DataRecord(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload()));
+ }
+
+ public static build.buf.gen.tcnapi.exile.gate.v3.Record fromRecord(DataRecord r) {
+ return build.buf.gen.tcnapi.exile.gate.v3.Record.newBuilder()
+ .setPoolId(r.poolId())
+ .setRecordId(r.recordId())
+ .setPayload(mapToStruct(r.payload()))
+ .build();
+ }
+
+ public static Field toField(build.buf.gen.tcnapi.exile.gate.v3.Field f) {
+ return new Field(f.getFieldName(), f.getFieldValue(), f.getPoolId(), f.getRecordId());
+ }
+
+ public static build.buf.gen.tcnapi.exile.gate.v3.Field fromField(Field f) {
+ return build.buf.gen.tcnapi.exile.gate.v3.Field.newBuilder()
+ .setFieldName(f.fieldName())
+ .setFieldValue(f.fieldValue())
+ .setPoolId(f.poolId())
+ .setRecordId(f.recordId())
+ .build();
+ }
+
+ public static Filter toFilter(build.buf.gen.tcnapi.exile.gate.v3.Filter f) {
+ return new Filter(
+ f.getField(),
+ switch (f.getOperator()) {
+ case OPERATOR_EQUAL -> Filter.Operator.EQUAL;
+ case OPERATOR_NOT_EQUAL -> Filter.Operator.NOT_EQUAL;
+ case OPERATOR_CONTAINS -> Filter.Operator.CONTAINS;
+ case OPERATOR_GREATER_THAN -> Filter.Operator.GREATER_THAN;
+ case OPERATOR_LESS_THAN -> Filter.Operator.LESS_THAN;
+ case OPERATOR_IN -> Filter.Operator.IN;
+ case OPERATOR_EXISTS -> Filter.Operator.EXISTS;
+ default -> Filter.Operator.UNSPECIFIED;
+ },
+ f.getValue());
+ }
+
+ public static build.buf.gen.tcnapi.exile.gate.v3.Filter fromFilter(Filter f) {
+ return build.buf.gen.tcnapi.exile.gate.v3.Filter.newBuilder()
+ .setField(f.field())
+ .setOperator(
+ switch (f.operator()) {
+ case EQUAL -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_EQUAL;
+ case NOT_EQUAL ->
+ build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_NOT_EQUAL;
+ case CONTAINS -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_CONTAINS;
+ case GREATER_THAN ->
+ build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_GREATER_THAN;
+ case LESS_THAN ->
+ build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_LESS_THAN;
+ case IN -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_IN;
+ case EXISTS -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_EXISTS;
+ default -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_UNSPECIFIED;
+ })
+ .setValue(f.value())
+ .build();
+ }
+
+ public static List toTaskData(List list) {
+ return list.stream()
+ .map(td -> new TaskData(td.getKey(), valueToObject(td.getValue())))
+ .collect(Collectors.toList());
+ }
+
+ // ---- Agent ----
+
+ public static Agent toAgent(build.buf.gen.tcnapi.exile.gate.v3.Agent a) {
+ Optional cp =
+ a.hasConnectedParty()
+ ? Optional.of(
+ new Agent.ConnectedParty(
+ a.getConnectedParty().getCallSid(),
+ toCallType(a.getConnectedParty().getCallType()),
+ a.getConnectedParty().getIsInbound()))
+ : Optional.empty();
+ return new Agent(
+ a.getUserId(),
+ a.getOrgId(),
+ a.getFirstName(),
+ a.getLastName(),
+ a.getUsername(),
+ a.getPartnerAgentId(),
+ a.getCurrentSessionId(),
+ toAgentState(a.getAgentState()),
+ a.getIsLoggedIn(),
+ a.getIsMuted(),
+ a.getIsRecording(),
+ cp);
+ }
+
+ public static Skill toSkill(build.buf.gen.tcnapi.exile.gate.v3.Skill s) {
+ return new Skill(
+ s.getSkillId(),
+ s.getName(),
+ s.getDescription(),
+ s.hasProficiency() ? OptionalLong.of(s.getProficiency()) : OptionalLong.empty());
+ }
+
+ // ---- Events ----
+
+ public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.gate.v3.AgentCall ac) {
+ return new AgentCallEvent(
+ ac.getAgentCallSid(),
+ ac.getCallSid(),
+ toCallType(ac.getCallType()),
+ ac.getOrgId(),
+ ac.getUserId(),
+ ac.getPartnerAgentId(),
+ ac.getInternalKey(),
+ toDuration(ac.getTalkDuration()),
+ toDuration(ac.getWaitDuration()),
+ toDuration(ac.getWrapupDuration()),
+ toDuration(ac.getPauseDuration()),
+ toDuration(ac.getTransferDuration()),
+ toDuration(ac.getManualDuration()),
+ toDuration(ac.getPreviewDuration()),
+ toDuration(ac.getHoldDuration()),
+ toDuration(ac.getAgentWaitDuration()),
+ toDuration(ac.getSuspendedDuration()),
+ toDuration(ac.getExternalTransferDuration()),
+ toInstant(ac.getCreateTime()),
+ toInstant(ac.getUpdateTime()),
+ toTaskData(ac.getTaskDataList()));
+ }
+
+ public static TelephonyResultEvent toTelephonyResultEvent(
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult tr) {
+ return new TelephonyResultEvent(
+ tr.getCallSid(),
+ toCallType(tr.getCallType()),
+ tr.getOrgId(),
+ tr.getInternalKey(),
+ tr.getCallerId(),
+ tr.getPhoneNumber(),
+ tr.getPoolId(),
+ tr.getRecordId(),
+ tr.getClientSid(),
+ tr.getStatus().name(),
+ tr.hasOutcome() ? tr.getOutcome().getCategory().name() : "",
+ tr.hasOutcome() ? tr.getOutcome().getDetail().name() : "",
+ toDuration(tr.getDeliveryLength()),
+ toDuration(tr.getLinkbackLength()),
+ toInstant(tr.getCreateTime()),
+ toInstant(tr.getUpdateTime()),
+ toInstant(tr.getStartTime()),
+ toInstant(tr.getEndTime()),
+ toTaskData(tr.getTaskDataList()));
+ }
+
+ public static AgentResponseEvent toAgentResponseEvent(
+ build.buf.gen.tcnapi.exile.gate.v3.AgentResponse ar) {
+ return new AgentResponseEvent(
+ ar.getAgentCallResponseSid(),
+ ar.getCallSid(),
+ toCallType(ar.getCallType()),
+ ar.getOrgId(),
+ ar.getUserId(),
+ ar.getPartnerAgentId(),
+ ar.getInternalKey(),
+ ar.getAgentSid(),
+ ar.getClientSid(),
+ ar.getResponseKey(),
+ ar.getResponseValue(),
+ toInstant(ar.getCreateTime()),
+ toInstant(ar.getUpdateTime()));
+ }
+
+ public static CallRecordingEvent toCallRecordingEvent(
+ build.buf.gen.tcnapi.exile.gate.v3.CallRecording cr) {
+ return new CallRecordingEvent(
+ cr.getRecordingId(),
+ cr.getOrgId(),
+ cr.getCallSid(),
+ toCallType(cr.getCallType()),
+ toDuration(cr.getDuration()),
+ cr.getRecordingType().name(),
+ toInstant(cr.getStartTime()));
+ }
+
+ public static TransferInstanceEvent toTransferInstanceEvent(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferInstance ti) {
+ var src = ti.getSource();
+ return new TransferInstanceEvent(
+ ti.getClientSid(),
+ ti.getOrgId(),
+ ti.getTransferInstanceId(),
+ new TransferInstanceEvent.Source(
+ src.getCallSid(),
+ toCallType(src.getCallType()),
+ src.getPartnerAgentId(),
+ src.getUserId(),
+ src.getConversationId(),
+ src.getSessionSid(),
+ src.getAgentCallSid()),
+ ti.getTransferType().name(),
+ ti.getTransferResult().name(),
+ ti.getInitiation().name(),
+ toInstant(ti.getCreateTime()),
+ toInstant(ti.getTransferTime()),
+ toInstant(ti.getAcceptTime()),
+ toInstant(ti.getHangupTime()),
+ toInstant(ti.getEndTime()),
+ toInstant(ti.getUpdateTime()),
+ toDuration(ti.getPendingDuration()),
+ toDuration(ti.getExternalDuration()),
+ toDuration(ti.getFullDuration()));
+ }
+
+ public static TaskEvent toTaskEvent(build.buf.gen.tcnapi.exile.gate.v3.ExileTask t) {
+ return new TaskEvent(
+ t.getTaskSid(),
+ t.getTaskGroupSid(),
+ t.getOrgId(),
+ t.getClientSid(),
+ t.getPoolId(),
+ t.getRecordId(),
+ t.getAttempts(),
+ t.getStatus().name(),
+ toInstant(t.getCreateTime()),
+ toInstant(t.getUpdateTime()));
+ }
+
+ // ---- Struct ↔ Map ----
+
+ public static Map structToMap(com.google.protobuf.Struct s) {
+ if (s == null) return Map.of();
+ Map map = new LinkedHashMap<>();
+ s.getFieldsMap().forEach((k, v) -> map.put(k, valueToObject(v)));
+ return map;
+ }
+
+ public static com.google.protobuf.Struct mapToStruct(Map map) {
+ if (map == null || map.isEmpty()) return com.google.protobuf.Struct.getDefaultInstance();
+ var builder = com.google.protobuf.Struct.newBuilder();
+ map.forEach((k, v) -> builder.putFields(k, objectToValue(v)));
+ return builder.build();
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Object valueToObject(com.google.protobuf.Value v) {
+ if (v == null) return null;
+ return switch (v.getKindCase()) {
+ case STRING_VALUE -> v.getStringValue();
+ case NUMBER_VALUE -> v.getNumberValue();
+ case BOOL_VALUE -> v.getBoolValue();
+ case NULL_VALUE -> null;
+ case STRUCT_VALUE -> structToMap(v.getStructValue());
+ case LIST_VALUE ->
+ v.getListValue().getValuesList().stream()
+ .map(ProtoConverter::valueToObject)
+ .collect(Collectors.toList());
+ default -> null;
+ };
+ }
+
+ @SuppressWarnings("unchecked")
+ public static com.google.protobuf.Value objectToValue(Object obj) {
+ if (obj == null) {
+ return com.google.protobuf.Value.newBuilder()
+ .setNullValue(com.google.protobuf.NullValue.NULL_VALUE)
+ .build();
+ }
+ if (obj instanceof String s) {
+ return com.google.protobuf.Value.newBuilder().setStringValue(s).build();
+ }
+ if (obj instanceof Number n) {
+ return com.google.protobuf.Value.newBuilder().setNumberValue(n.doubleValue()).build();
+ }
+ if (obj instanceof Boolean b) {
+ return com.google.protobuf.Value.newBuilder().setBoolValue(b).build();
+ }
+ if (obj instanceof Map, ?> m) {
+ return com.google.protobuf.Value.newBuilder()
+ .setStructValue(mapToStruct((Map) m))
+ .build();
+ }
+ if (obj instanceof List> l) {
+ var lb = com.google.protobuf.ListValue.newBuilder();
+ for (Object item : l) lb.addValues(objectToValue(item));
+ return com.google.protobuf.Value.newBuilder().setListValue(lb).build();
+ }
+ return com.google.protobuf.Value.newBuilder().setStringValue(obj.toString()).build();
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java
new file mode 100644
index 0000000..90b3b7c
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java
@@ -0,0 +1,644 @@
+package com.tcn.exile.internal;
+
+import static com.tcn.exile.internal.ProtoConverter.*;
+
+import build.buf.gen.tcnapi.exile.gate.v3.*;
+import com.tcn.exile.ExileConfig;
+import com.tcn.exile.StreamStatus;
+import com.tcn.exile.StreamStatus.Phase;
+import com.tcn.exile.handler.EventHandler;
+import com.tcn.exile.handler.JobHandler;
+import com.tcn.exile.model.*;
+import io.grpc.ManagedChannel;
+import io.grpc.stub.StreamObserver;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanContext;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.TraceFlags;
+import io.opentelemetry.api.trace.TraceState;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.Scope;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+
+/**
+ * Implements the v3 WorkStream protocol over a single bidirectional gRPC stream.
+ *
+ * This class is internal. The public API is {@link com.tcn.exile.ExileClient}. All proto types
+ * are converted to/from plain Java types at the boundary — handlers never see proto classes.
+ */
+public final class WorkStreamClient implements AutoCloseable {
+
+ private static final Logger log = LoggerFactory.getLogger(WorkStreamClient.class);
+
+ private final ExileConfig config;
+ private final JobHandler jobHandler;
+ private final EventHandler eventHandler;
+ private final String clientName;
+ private final String clientVersion;
+ private final int maxConcurrency;
+ private final List capabilities;
+
+ private final AtomicBoolean running = new AtomicBoolean(false);
+ private final AtomicReference> requestObserver =
+ new AtomicReference<>();
+ private final AtomicInteger inflight = new AtomicInteger(0);
+ private final ExecutorService workerPool = Executors.newVirtualThreadPerTaskExecutor();
+
+ // Maps work_id → SpanContext so async responses (ResultAccepted, etc.) can log with trace
+ // context.
+ private final java.util.concurrent.ConcurrentHashMap workSpanContexts =
+ new java.util.concurrent.ConcurrentHashMap<>();
+
+ // Status tracking.
+ private volatile Phase phase = Phase.IDLE;
+ private volatile String clientId;
+ private volatile Instant connectedSince;
+ private volatile Instant lastDisconnect;
+ private volatile String lastError;
+ private final AtomicLong completedTotal = new AtomicLong(0);
+ private final AtomicLong failedTotal = new AtomicLong(0);
+ private final AtomicLong reconnectAttempts = new AtomicLong(0);
+
+ private volatile ManagedChannel channel;
+ private volatile Thread streamThread;
+ private volatile java.util.function.DoubleConsumer durationRecorder;
+ private volatile java.util.function.DoubleConsumer reconnectRecorder;
+ private volatile boolean lastDisconnectGraceful;
+ private volatile MethodRecorder methodRecorder;
+
+ /** Callback to record per-method metrics (name, duration, success). */
+ @FunctionalInterface
+ public interface MethodRecorder {
+ void record(String method, double durationSeconds, boolean success);
+ }
+
+ public WorkStreamClient(
+ ExileConfig config,
+ JobHandler jobHandler,
+ EventHandler eventHandler,
+ String clientName,
+ String clientVersion,
+ int maxConcurrency,
+ List capabilities) {
+ this.config = config;
+ this.jobHandler = jobHandler;
+ this.eventHandler = eventHandler;
+ this.clientName = clientName;
+ this.clientVersion = clientVersion;
+ this.maxConcurrency = maxConcurrency;
+ this.capabilities = capabilities;
+ }
+
+ /** Returns a snapshot of the stream's current state. */
+ public StreamStatus status() {
+ return new StreamStatus(
+ phase,
+ clientId,
+ connectedSince,
+ lastDisconnect,
+ lastError,
+ inflight.get(),
+ completedTotal.get(),
+ failedTotal.get(),
+ reconnectAttempts.get());
+ }
+
+ public void start() {
+ if (!running.compareAndSet(false, true)) {
+ throw new IllegalStateException("Already started");
+ }
+ log.debug("Creating gRPC channel to {}:{}", config.apiHostname(), config.apiPort());
+ channel = ChannelFactory.create(config);
+ log.debug("Channel created");
+ streamThread =
+ Thread.ofPlatform().name("exile-work-stream").daemon(true).start(this::reconnectLoop);
+ }
+
+ private void reconnectLoop() {
+ var backoff = new Backoff();
+ while (running.get()) {
+ try {
+ long delayMs = backoff.nextDelayMs();
+ if (delayMs > 0) {
+ phase = Phase.RECONNECTING;
+ log.info("Reconnecting in {}ms (attempt #{})", delayMs, reconnectAttempts.get() + 1);
+ }
+ backoff.sleep();
+ long attempt = reconnectAttempts.incrementAndGet();
+ log.info(
+ "Connecting to {}:{} (attempt #{})", config.apiHostname(), config.apiPort(), attempt);
+ runStream();
+ // Only reset backoff if the stream ended gracefully (RST_STREAM NO_ERROR or server close).
+ // UNAVAILABLE and other errors should trigger backoff to avoid hammering the server.
+ if (lastDisconnectGraceful) {
+ backoff.reset();
+ } else {
+ backoff.recordFailure();
+ }
+ lastDisconnectGraceful = false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Exception e) {
+ backoff.recordFailure();
+ lastDisconnect = Instant.now();
+ lastError = e.getClass().getSimpleName() + ": " + e.getMessage();
+ connectedSince = null;
+ clientId = null;
+ lastDisconnectGraceful = false;
+ log.warn("Stream disconnected ({}): {}", e.getClass().getSimpleName(), e.getMessage());
+ }
+ }
+ phase = Phase.CLOSED;
+ log.info("Work stream loop exited");
+ }
+
+ private void runStream() throws InterruptedException {
+ phase = Phase.CONNECTING;
+ log.debug("Opening WorkStream on existing channel");
+ try {
+ var stub = WorkerServiceGrpc.newStub(channel);
+ var latch = new CountDownLatch(1);
+
+ var observer =
+ stub.workStream(
+ new StreamObserver<>() {
+ @Override
+ public void onNext(WorkResponse response) {
+ handleResponse(response);
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ lastError = t.getClass().getSimpleName() + ": " + t.getMessage();
+ lastDisconnect = Instant.now();
+ connectedSince = null;
+ // RST_STREAM with NO_ERROR is envoy recycling the stream, not a real failure.
+ String msg = t.getMessage();
+ lastDisconnectGraceful =
+ msg != null && msg.contains("RST_STREAM") && msg.contains("NO_ERROR");
+ log.warn("Stream error ({}): {}", t.getClass().getSimpleName(), t.getMessage());
+ // Log the full cause chain for SSL errors to aid debugging.
+ for (Throwable cause = t.getCause(); cause != null; cause = cause.getCause()) {
+ log.warn(
+ " caused by ({}): {}",
+ cause.getClass().getSimpleName(),
+ cause.getMessage());
+ }
+ latch.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ lastDisconnect = Instant.now();
+ connectedSince = null;
+ lastDisconnectGraceful = true;
+ log.info("Stream completed by server");
+ latch.countDown();
+ }
+ });
+
+ requestObserver.set(observer);
+
+ // Register.
+ phase = Phase.REGISTERING;
+ send(
+ WorkRequest.newBuilder()
+ .setRegister(
+ Register.newBuilder()
+ .setClientName(clientName)
+ .setClientVersion(clientVersion)
+ .addAllCapabilities(capabilities))
+ .build());
+
+ // Wait until stream ends.
+ latch.await();
+ } finally {
+ requestObserver.set(null);
+ inflight.set(0);
+ // Channel is reused across reconnects — only shut down on close().
+ }
+ }
+
+ private void handleResponse(WorkResponse response) {
+ switch (response.getPayloadCase()) {
+ case REGISTERED -> {
+ var reg = response.getRegistered();
+ clientId = reg.getClientId();
+ var now = Instant.now();
+ // Record reconnect duration if this is a re-registration.
+ var disconnectTime = lastDisconnect;
+ if (disconnectTime != null) {
+ double reconnectSec = java.time.Duration.between(disconnectTime, now).toMillis() / 1000.0;
+ var rr = reconnectRecorder;
+ if (rr != null) rr.accept(reconnectSec);
+ log.info(
+ "Reconnected in {}ms", java.time.Duration.between(disconnectTime, now).toMillis());
+ lastDisconnect = null;
+ }
+ connectedSince = now;
+ phase = Phase.ACTIVE;
+ log.info(
+ "Registered as {} (heartbeat={}s, lease={}s, max_inflight={})",
+ reg.getClientId(),
+ reg.getHeartbeatInterval().getSeconds(),
+ reg.getDefaultLease().getSeconds(),
+ reg.getMaxInflight());
+ // Signal the gate to start sending events. The gate pushes continuously;
+ // gRPC HTTP/2 flow control handles backpressure if we can't keep up.
+ pull(Integer.MAX_VALUE);
+ }
+ case WORK_ITEM -> {
+ inflight.incrementAndGet();
+ workerPool.submit(() -> processWorkItem(response.getWorkItem()));
+ }
+ case RESULT_ACCEPTED -> {
+ var workId = response.getResultAccepted().getWorkId();
+ withWorkSpan(workId, () -> log.debug("Result accepted: {}", workId));
+ workSpanContexts.remove(workId);
+ }
+ case LEASE_EXPIRING -> {
+ var w = response.getLeaseExpiring();
+ withWorkSpan(
+ w.getWorkId(),
+ () ->
+ log.debug(
+ "Lease expiring for {}, {}s remaining",
+ w.getWorkId(),
+ w.getRemaining().getSeconds()));
+ send(
+ WorkRequest.newBuilder()
+ .setExtendLease(
+ ExtendLease.newBuilder()
+ .setWorkId(w.getWorkId())
+ .setExtension(com.google.protobuf.Duration.newBuilder().setSeconds(300)))
+ .build());
+ }
+ case HEARTBEAT ->
+ send(
+ WorkRequest.newBuilder()
+ .setHeartbeat(
+ Heartbeat.newBuilder()
+ .setClientTime(
+ com.google.protobuf.Timestamp.newBuilder()
+ .setSeconds(Instant.now().getEpochSecond())))
+ .build());
+ case ERROR -> {
+ var err = response.getError();
+ lastError = err.getCode() + ": " + err.getMessage();
+ withWorkSpan(
+ err.getWorkId(),
+ () ->
+ log.warn(
+ "Stream error for {}: {} - {}",
+ err.getWorkId(),
+ err.getCode(),
+ err.getMessage()));
+ }
+ default -> {}
+ }
+ }
+
+ /** Set a callback to record work item processing duration (in seconds). */
+ public void setDurationRecorder(java.util.function.DoubleConsumer recorder) {
+ this.durationRecorder = recorder;
+ }
+
+ /** Set a callback to record per-method metrics. */
+ public void setMethodRecorder(MethodRecorder recorder) {
+ this.methodRecorder = recorder;
+ }
+
+ /** Set a callback to record reconnect duration (in seconds). */
+ public void setReconnectRecorder(java.util.function.DoubleConsumer recorder) {
+ this.reconnectRecorder = recorder;
+ }
+
+ private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.tcn.exile.sati", "1.0.0");
+
+ /** Run a block with the span context of a work item temporarily set as current. */
+ private void withWorkSpan(String workId, Runnable action) {
+ var sc = workSpanContexts.get(workId);
+ if (sc != null) {
+ try (Scope ignored = Context.current().with(Span.wrap(sc)).makeCurrent()) {
+ MDC.put("traceId", sc.getTraceId());
+ MDC.put("spanId", sc.getSpanId());
+ action.run();
+ } finally {
+ MDC.remove("traceId");
+ MDC.remove("spanId");
+ }
+ } else {
+ action.run();
+ }
+ }
+
+ /** Parse a W3C traceparent string ("00-traceId-spanId-flags") into a SpanContext. */
+ private static SpanContext parseTraceParent(String traceParent) {
+ if (traceParent == null || traceParent.isEmpty()) return null;
+ String[] parts = traceParent.split("-");
+ if (parts.length < 4) return null;
+ try {
+ return SpanContext.createFromRemoteParent(
+ parts[1],
+ parts[2],
+ TraceFlags.fromByte(Byte.parseByte(parts[3], 16)),
+ TraceState.getDefault());
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private void processWorkItem(WorkItem item) {
+ long startNanos = System.nanoTime();
+ String workId = item.getWorkId();
+ String category = item.getCategory() == WorkCategory.WORK_CATEGORY_JOB ? "job" : "event";
+
+ var spanBuilder =
+ tracer
+ .spanBuilder("exile.work." + category)
+ .setSpanKind(SpanKind.CONSUMER)
+ .setAttribute("exile.work_id", workId)
+ .setAttribute("exile.work_category", category);
+
+ // Link to the upstream trace from the gate if trace_parent is set.
+ var parentCtx = parseTraceParent(item.getTraceParent());
+ if (parentCtx != null) {
+ spanBuilder.setParent(Context.current().with(Span.wrap(parentCtx)));
+ }
+
+ Span span = spanBuilder.startSpan();
+ workSpanContexts.put(workId, span.getSpanContext());
+
+ try (Scope ignored = span.makeCurrent()) {
+ MDC.put("traceId", span.getSpanContext().getTraceId());
+ MDC.put("spanId", span.getSpanContext().getSpanId());
+ if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) {
+ var result = dispatchJob(item);
+ send(WorkRequest.newBuilder().setResult(result).build());
+ } else {
+ dispatchEvent(item);
+ send(WorkRequest.newBuilder().setAck(Ack.newBuilder().addWorkIds(workId)).build());
+ }
+ completedTotal.incrementAndGet();
+ } catch (Exception e) {
+ span.setStatus(StatusCode.ERROR, e.getMessage());
+ span.recordException(e);
+ failedTotal.incrementAndGet();
+ log.warn("Work item {} failed: {}", workId, e.getMessage());
+ if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) {
+ send(
+ WorkRequest.newBuilder()
+ .setResult(
+ Result.newBuilder()
+ .setWorkId(workId)
+ .setFinal(true)
+ .setError(ErrorResult.newBuilder().setMessage(e.getMessage())))
+ .build());
+ } else {
+ send(
+ WorkRequest.newBuilder()
+ .setNack(Nack.newBuilder().setWorkId(workId).setReason(e.getMessage()))
+ .build());
+ }
+ } finally {
+ MDC.remove("traceId");
+ MDC.remove("spanId");
+ span.end();
+ // For events (ACK), clean up now — no RESULT_ACCEPTED will come.
+ // For jobs, clean up in handleResponse when RESULT_ACCEPTED is received.
+ if (item.getCategory() != WorkCategory.WORK_CATEGORY_JOB) {
+ workSpanContexts.remove(workId);
+ }
+ var recorder = durationRecorder;
+ if (recorder != null) {
+ recorder.accept((System.nanoTime() - startNanos) / 1_000_000_000.0);
+ }
+ inflight.decrementAndGet();
+ // Periodic pull thread handles capacity signaling.
+ }
+ }
+
+ private Result.Builder dispatchJob(WorkItem item) throws Exception {
+ var b = Result.newBuilder().setWorkId(item.getWorkId()).setFinal(true);
+ var methodName = item.getTaskCase().name().toLowerCase();
+ long methodStart = System.nanoTime();
+ boolean methodSuccess = false;
+ try {
+ switch (item.getTaskCase()) {
+ case LIST_POOLS -> {
+ var pools = jobHandler.listPools();
+ b.setListPools(
+ ListPoolsResult.newBuilder()
+ .addAllPools(pools.stream().map(ProtoConverter::fromPool).toList()));
+ }
+ case GET_POOL_STATUS -> {
+ var pool = jobHandler.getPoolStatus(item.getGetPoolStatus().getPoolId());
+ b.setGetPoolStatus(GetPoolStatusResult.newBuilder().setPool(fromPool(pool)));
+ }
+ case GET_POOL_RECORDS -> {
+ var task = item.getGetPoolRecords();
+ var page =
+ jobHandler.getPoolRecords(task.getPoolId(), task.getPageToken(), task.getPageSize());
+ b.setGetPoolRecords(
+ GetPoolRecordsResult.newBuilder()
+ .addAllRecords(
+ page.items().stream()
+ .map(
+ r -> ProtoConverter.fromRecord(r))
+ .toList())
+ .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : ""));
+ }
+ case SEARCH_RECORDS -> {
+ var task = item.getSearchRecords();
+ var filters = task.getFiltersList().stream().map(ProtoConverter::toFilter).toList();
+ var page = jobHandler.searchRecords(filters, task.getPageToken(), task.getPageSize());
+ b.setSearchRecords(
+ SearchRecordsResult.newBuilder()
+ .addAllRecords(
+ page.items().stream()
+ .map(
+ r -> ProtoConverter.fromRecord(r))
+ .toList())
+ .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : ""));
+ }
+ case GET_RECORD_FIELDS -> {
+ var task = item.getGetRecordFields();
+ var fields =
+ jobHandler.getRecordFields(
+ task.getPoolId(), task.getRecordId(), task.getFieldNamesList());
+ b.setGetRecordFields(
+ GetRecordFieldsResult.newBuilder()
+ .addAllFields(fields.stream().map(ProtoConverter::fromField).toList()));
+ }
+ case SET_RECORD_FIELDS -> {
+ var task = item.getSetRecordFields();
+ var fields = task.getFieldsList().stream().map(ProtoConverter::toField).toList();
+ var ok = jobHandler.setRecordFields(task.getPoolId(), task.getRecordId(), fields);
+ b.setSetRecordFields(SetRecordFieldsResult.newBuilder().setSuccess(ok));
+ }
+ case CREATE_PAYMENT -> {
+ var task = item.getCreatePayment();
+ var paymentId =
+ jobHandler.createPayment(
+ task.getPoolId(), task.getRecordId(), structToMap(task.getPaymentData()));
+ b.setCreatePayment(
+ CreatePaymentResult.newBuilder().setSuccess(true).setPaymentId(paymentId));
+ }
+ case POP_ACCOUNT -> {
+ var task = item.getPopAccount();
+ var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId());
+ b.setPopAccount(PopAccountResult.newBuilder().setRecord(fromRecord(record)));
+ }
+ case EXECUTE_LOGIC -> {
+ var task = item.getExecuteLogic();
+ var output =
+ jobHandler.executeLogic(task.getLogicName(), structToMap(task.getParameters()));
+ b.setExecuteLogic(ExecuteLogicResult.newBuilder().setOutput(mapToStruct(output)));
+ }
+ case INFO -> {
+ var info = jobHandler.info();
+ var ib = InfoResult.newBuilder();
+ if (info.containsKey("appName")) ib.setAppName((String) info.get("appName"));
+ if (info.containsKey("appVersion")) ib.setAppVersion((String) info.get("appVersion"));
+ ib.setMetadata(mapToStruct(info));
+ b.setInfo(ib);
+ }
+ case SHUTDOWN -> {
+ jobHandler.shutdown(item.getShutdown().getReason());
+ b.setShutdown(ShutdownResult.getDefaultInstance());
+ }
+ case LOGGING -> {
+ jobHandler.processLog(item.getLogging().getPayload());
+ b.setLogging(LoggingResult.getDefaultInstance());
+ }
+ case DIAGNOSTICS -> {
+ var diag = jobHandler.diagnostics();
+ b.setDiagnostics(
+ DiagnosticsResult.newBuilder()
+ .setSystemInfo(mapToStruct(diag.systemInfo()))
+ .setRuntimeInfo(mapToStruct(diag.runtimeInfo()))
+ .setDatabaseInfo(mapToStruct(diag.databaseInfo()))
+ .setCustom(mapToStruct(diag.custom())));
+ }
+ case LIST_TENANT_LOGS -> {
+ var task = item.getListTenantLogs();
+ var page =
+ jobHandler.listTenantLogs(
+ toInstant(task.getStartTime()),
+ toInstant(task.getEndTime()),
+ task.getPageToken(),
+ task.getPageSize());
+ var rb =
+ ListTenantLogsResult.newBuilder()
+ .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "");
+ for (var entry : page.items()) {
+ rb.addEntries(
+ ListTenantLogsResult.LogEntry.newBuilder()
+ .setTimestamp(fromInstant(entry.timestamp()))
+ .setLevel(entry.level())
+ .setLogger(entry.logger())
+ .setMessage(entry.message()));
+ }
+ b.setListTenantLogs(rb);
+ }
+ case SET_LOG_LEVEL -> {
+ var task = item.getSetLogLevel();
+ jobHandler.setLogLevel(task.getLoggerName(), task.getLevel());
+ b.setSetLogLevel(SetLogLevelResult.getDefaultInstance());
+ }
+ default -> throw new UnsupportedOperationException("Unknown job: " + item.getTaskCase());
+ }
+ methodSuccess = true;
+ return b;
+ } finally {
+ var mr = methodRecorder;
+ if (mr != null) {
+ mr.record(methodName, (System.nanoTime() - methodStart) / 1_000_000_000.0, methodSuccess);
+ }
+ }
+ }
+
+ private void dispatchEvent(WorkItem item) throws Exception {
+ var methodName = item.getTaskCase().name().toLowerCase();
+ long methodStart = System.nanoTime();
+ boolean methodSuccess = false;
+ try {
+ switch (item.getTaskCase()) {
+ case AGENT_CALL -> eventHandler.onAgentCall(toAgentCallEvent(item.getAgentCall()));
+ case TELEPHONY_RESULT ->
+ eventHandler.onTelephonyResult(toTelephonyResultEvent(item.getTelephonyResult()));
+ case AGENT_RESPONSE ->
+ eventHandler.onAgentResponse(toAgentResponseEvent(item.getAgentResponse()));
+ case TRANSFER_INSTANCE ->
+ eventHandler.onTransferInstance(toTransferInstanceEvent(item.getTransferInstance()));
+ case CALL_RECORDING ->
+ eventHandler.onCallRecording(toCallRecordingEvent(item.getCallRecording()));
+ case EXILE_TASK -> eventHandler.onTask(toTaskEvent(item.getExileTask()));
+ default -> throw new UnsupportedOperationException("Unknown event: " + item.getTaskCase());
+ }
+ methodSuccess = true;
+ } finally {
+ var mr = methodRecorder;
+ if (mr != null) {
+ mr.record(methodName, (System.nanoTime() - methodStart) / 1_000_000_000.0, methodSuccess);
+ }
+ }
+ }
+
+ private void pull(int count) {
+ send(WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(count)).build());
+ }
+
+ private void send(WorkRequest request) {
+ var observer = requestObserver.get();
+ if (observer != null) {
+ try {
+ synchronized (observer) {
+ observer.onNext(request);
+ }
+ } catch (Exception e) {
+ log.warn("Failed to send {}: {}", request.getActionCase(), e.getMessage());
+ // Stream is broken — cancel it so the reconnect loop picks up immediately
+ // instead of waiting for the next Recv to fail.
+ var current = requestObserver.getAndSet(null);
+ if (current != null) {
+ try {
+ current.onError(e);
+ } catch (Exception ignored) {
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ running.set(false);
+ phase = Phase.CLOSED;
+ if (streamThread != null) streamThread.interrupt();
+ var observer = requestObserver.getAndSet(null);
+ if (observer != null) {
+ try {
+ observer.onCompleted();
+ } catch (Exception ignored) {
+ }
+ }
+ workerPool.close();
+ if (channel != null) ChannelFactory.shutdown(channel);
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/log/LogCategory.java b/core/src/main/java/com/tcn/exile/log/LogCategory.java
deleted file mode 100644
index 009e4fd..0000000
--- a/core/src/main/java/com/tcn/exile/log/LogCategory.java
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.log;
-
-/**
- * Defines the categories of log messages for structured logging. Each category represents a
- * specific area of the application.
- */
-public enum LogCategory {
- STARTUP("startup"), // Application lifecycle
- CONFIG("config"), // Configuration changes
- DATABASE("database"), // Database operations
- GRPC("grpc"), // gRPC communications
- PLUGIN("plugin"), // Plugin lifecycle and jobs
- QUEUE("queue"), // Job queue operations
- API("api"), // REST API operations
- SECURITY("security"), // Authentication/authorization
- PERFORMANCE("performance"); // Performance metrics
-
- private final String value;
-
- LogCategory(String value) {
- this.value = value;
- }
-
- public String getValue() {
- return value;
- }
-
- @Override
- public String toString() {
- return value;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/log/LoggerLevels.java b/core/src/main/java/com/tcn/exile/log/LoggerLevels.java
deleted file mode 100644
index 6d5a3be..0000000
--- a/core/src/main/java/com/tcn/exile/log/LoggerLevels.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.log;
-
-import io.micronaut.context.annotation.ConfigurationProperties;
-import io.micronaut.serde.annotation.Serdeable;
-import java.util.HashMap;
-
-@Serdeable
-@ConfigurationProperties("logger")
-public class LoggerLevels {
- private HashMap levels = new HashMap<>();
-
- public HashMap getLevels() {
- return levels;
- }
-
- public LoggerLevels setLevels(HashMap levels) {
- this.levels = levels;
- return this;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/log/StructuredLogger.java b/core/src/main/java/com/tcn/exile/log/StructuredLogger.java
deleted file mode 100644
index a57f4f9..0000000
--- a/core/src/main/java/com/tcn/exile/log/StructuredLogger.java
+++ /dev/null
@@ -1,192 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.log;
-
-import java.util.UUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.slf4j.MDC;
-
-/**
- * Utility class for structured logging with standardized message formats. Provides methods for
- * logging with consistent context and formatting.
- */
-public class StructuredLogger {
- private final Logger logger;
- private final String component;
-
- /**
- * Creates a new StructuredLogger for the specified class.
- *
- * @param clazz The class to create a logger for
- */
- public StructuredLogger(Class> clazz) {
- this.logger = LoggerFactory.getLogger(clazz);
- this.component = clazz.getSimpleName();
- }
-
- /**
- * Creates a new StructuredLogger with the specified name.
- *
- * @param name The name for the logger
- */
- public StructuredLogger(String name) {
- this.logger = LoggerFactory.getLogger(name);
- this.component = name;
- }
-
- /**
- * Sets up the MDC context for a request.
- *
- * @param tenant The tenant ID
- * @param userId The user ID (optional)
- * @param operation The operation name (optional)
- * @param jobId The job ID (optional)
- */
- public static void setupRequestContext(
- String tenant, String userId, String operation, String jobId) {
- MDC.put("tenant", tenant);
- MDC.put("requestId", UUID.randomUUID().toString());
-
- if (userId != null) {
- MDC.put("userId", userId);
- }
-
- if (operation != null) {
- MDC.put("operation", operation);
- }
-
- if (jobId != null) {
- MDC.put("jobId", jobId);
- }
- }
-
- /** Clears the MDC context. */
- public static void clearContext() {
- MDC.clear();
- }
-
- /**
- * Logs a message with the specified category and level.
- *
- * @param category The log category
- * @param level The log level
- * @param action The action being performed
- * @param message The log message
- * @param args The message arguments
- */
- private void log(
- LogCategory category, String level, String action, String message, Object... args) {
- String formattedMessage =
- String.format(
- "[%s] [%s] - %s",
- category.getValue(), action, args.length > 0 ? String.format(message, args) : message);
-
- switch (level) {
- case "DEBUG":
- logger.debug(formattedMessage);
- break;
- case "INFO":
- logger.info(formattedMessage);
- break;
- case "WARN":
- logger.warn(formattedMessage);
- break;
- case "ERROR":
- logger.error(formattedMessage);
- break;
- default:
- logger.info(formattedMessage);
- }
- }
-
- /**
- * Logs an info message.
- *
- * @param category The log category
- * @param action The action being performed
- * @param message The log message
- * @param args The message arguments
- */
- public void info(LogCategory category, String action, String message, Object... args) {
- log(category, "INFO", action, message, args);
- }
-
- /**
- * Logs a debug message.
- *
- * @param category The log category
- * @param action The action being performed
- * @param message The log message
- * @param args The message arguments
- */
- public void debug(LogCategory category, String action, String message, Object... args) {
- log(category, "DEBUG", action, message, args);
- }
-
- /**
- * Logs a warning message.
- *
- * @param category The log category
- * @param action The action being performed
- * @param message The log message
- * @param args The message arguments
- */
- public void warn(LogCategory category, String action, String message, Object... args) {
- log(category, "WARN", action, message, args);
- }
-
- /**
- * Logs an error message.
- *
- * @param category The log category
- * @param action The action being performed
- * @param message The log message
- * @param args The message arguments
- */
- public void error(LogCategory category, String action, String message, Object... args) {
- log(category, "ERROR", action, message, args);
- }
-
- /**
- * Logs an error message with an exception.
- *
- * @param category The log category
- * @param action The action being performed
- * @param message The log message
- * @param throwable The exception
- */
- public void error(LogCategory category, String action, String message, Throwable throwable) {
- String formattedMessage = String.format("[%s] [%s] - %s", category.getValue(), action, message);
- logger.error(formattedMessage, throwable);
- }
-
- /**
- * Logs a performance metric.
- *
- * @param metricName The name of the metric
- * @param value The metric value
- * @param unit The unit of measurement
- */
- public void metric(String metricName, double value, String unit) {
- String formattedMessage =
- String.format(
- "[%s] [METRIC] - %s: %.2f %s",
- LogCategory.PERFORMANCE.getValue(), metricName, value, unit);
- logger.info(formattedMessage);
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/model/Agent.java b/core/src/main/java/com/tcn/exile/model/Agent.java
new file mode 100644
index 0000000..496582c
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Agent.java
@@ -0,0 +1,20 @@
+package com.tcn.exile.model;
+
+import java.util.Optional;
+
+public record Agent(
+ String userId,
+ String orgId,
+ String firstName,
+ String lastName,
+ String username,
+ String partnerAgentId,
+ String currentSessionId,
+ AgentState state,
+ boolean loggedIn,
+ boolean muted,
+ boolean recording,
+ Optional connectedParty) {
+
+ public record ConnectedParty(long callSid, CallType callType, boolean inbound) {}
+}
diff --git a/core/src/main/java/com/tcn/exile/model/AgentState.java b/core/src/main/java/com/tcn/exile/model/AgentState.java
new file mode 100644
index 0000000..afea282
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/AgentState.java
@@ -0,0 +1,23 @@
+package com.tcn.exile.model;
+
+public enum AgentState {
+ UNSPECIFIED,
+ IDLE,
+ READY,
+ PEERED,
+ PAUSED,
+ WRAPUP,
+ PREPARING_PREVIEW,
+ PREVIEWING,
+ PREPARING_MANUAL,
+ DIALING,
+ INTERCOM,
+ WARM_TRANSFER_PENDING,
+ WARM_TRANSFER_ACTIVE,
+ COLD_TRANSFER_ACTIVE,
+ CONFERENCE_ACTIVE,
+ LOGGED_OUT,
+ SUSPENDED,
+ EXTERNAL_TRANSFER,
+ CALLBACK_SUSPENDED
+}
diff --git a/core/src/main/java/com/tcn/exile/model/CallType.java b/core/src/main/java/com/tcn/exile/model/CallType.java
new file mode 100644
index 0000000..83db387
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/CallType.java
@@ -0,0 +1,10 @@
+package com.tcn.exile.model;
+
+public enum CallType {
+ UNSPECIFIED,
+ INBOUND,
+ OUTBOUND,
+ PREVIEW,
+ MANUAL,
+ MAC
+}
diff --git a/core/src/main/java/com/tcn/exile/model/DataRecord.java b/core/src/main/java/com/tcn/exile/model/DataRecord.java
new file mode 100644
index 0000000..45cf955
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/DataRecord.java
@@ -0,0 +1,5 @@
+package com.tcn.exile.model;
+
+import java.util.Map;
+
+public record DataRecord(String poolId, String recordId, Map payload) {}
diff --git a/core/src/main/java/com/tcn/exile/model/Field.java b/core/src/main/java/com/tcn/exile/model/Field.java
new file mode 100644
index 0000000..2279289
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Field.java
@@ -0,0 +1,3 @@
+package com.tcn.exile.model;
+
+public record Field(String fieldName, String fieldValue, String poolId, String recordId) {}
diff --git a/core/src/main/java/com/tcn/exile/model/Filter.java b/core/src/main/java/com/tcn/exile/model/Filter.java
new file mode 100644
index 0000000..97c7df0
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Filter.java
@@ -0,0 +1,15 @@
+package com.tcn.exile.model;
+
+public record Filter(String field, Operator operator, String value) {
+
+ public enum Operator {
+ UNSPECIFIED,
+ EQUAL,
+ NOT_EQUAL,
+ CONTAINS,
+ GREATER_THAN,
+ LESS_THAN,
+ IN,
+ EXISTS
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/model/Page.java b/core/src/main/java/com/tcn/exile/model/Page.java
new file mode 100644
index 0000000..1a6ef99
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Page.java
@@ -0,0 +1,11 @@
+package com.tcn.exile.model;
+
+import java.util.List;
+
+/** A page of results with an optional continuation token. */
+public record Page(List items, String nextPageToken) {
+
+ public boolean hasMore() {
+ return nextPageToken != null && !nextPageToken.isEmpty();
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/model/Pool.java b/core/src/main/java/com/tcn/exile/model/Pool.java
new file mode 100644
index 0000000..ebc7ad9
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Pool.java
@@ -0,0 +1,11 @@
+package com.tcn.exile.model;
+
+public record Pool(String poolId, String description, PoolStatus status, long recordCount) {
+
+ public enum PoolStatus {
+ UNSPECIFIED,
+ READY,
+ NOT_READY,
+ BUSY
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/model/Skill.java b/core/src/main/java/com/tcn/exile/model/Skill.java
new file mode 100644
index 0000000..c6d9100
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/Skill.java
@@ -0,0 +1,5 @@
+package com.tcn.exile.model;
+
+import java.util.OptionalLong;
+
+public record Skill(String skillId, String name, String description, OptionalLong proficiency) {}
diff --git a/core/src/main/java/com/tcn/exile/model/TaskData.java b/core/src/main/java/com/tcn/exile/model/TaskData.java
new file mode 100644
index 0000000..0bfa84e
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/TaskData.java
@@ -0,0 +1,3 @@
+package com.tcn.exile.model;
+
+public record TaskData(String key, Object value) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java b/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java
new file mode 100644
index 0000000..9e16a02
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java
@@ -0,0 +1,30 @@
+package com.tcn.exile.model.event;
+
+import com.tcn.exile.model.CallType;
+import com.tcn.exile.model.TaskData;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+public record AgentCallEvent(
+ long agentCallSid,
+ long callSid,
+ CallType callType,
+ String orgId,
+ String userId,
+ String partnerAgentId,
+ String internalKey,
+ Duration talkDuration,
+ Duration waitDuration,
+ Duration wrapupDuration,
+ Duration pauseDuration,
+ Duration transferDuration,
+ Duration manualDuration,
+ Duration previewDuration,
+ Duration holdDuration,
+ Duration agentWaitDuration,
+ Duration suspendedDuration,
+ Duration externalTransferDuration,
+ Instant createTime,
+ Instant updateTime,
+ List taskData) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java b/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java
new file mode 100644
index 0000000..12c9faf
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java
@@ -0,0 +1,19 @@
+package com.tcn.exile.model.event;
+
+import com.tcn.exile.model.CallType;
+import java.time.Instant;
+
+public record AgentResponseEvent(
+ long agentCallResponseSid,
+ long callSid,
+ CallType callType,
+ String orgId,
+ String userId,
+ String partnerAgentId,
+ String internalKey,
+ long agentSid,
+ long clientSid,
+ String responseKey,
+ String responseValue,
+ Instant createTime,
+ Instant updateTime) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java b/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java
new file mode 100644
index 0000000..7794ddc
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java
@@ -0,0 +1,14 @@
+package com.tcn.exile.model.event;
+
+import com.tcn.exile.model.CallType;
+import java.time.Duration;
+import java.time.Instant;
+
+public record CallRecordingEvent(
+ String recordingId,
+ String orgId,
+ long callSid,
+ CallType callType,
+ Duration duration,
+ String recordingType,
+ Instant startTime) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java b/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java
new file mode 100644
index 0000000..27764d6
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java
@@ -0,0 +1,15 @@
+package com.tcn.exile.model.event;
+
+import java.time.Instant;
+
+public record TaskEvent(
+ long taskSid,
+ long taskGroupSid,
+ String orgId,
+ long clientSid,
+ String poolId,
+ String recordId,
+ long attempts,
+ String status,
+ Instant createTime,
+ Instant updateTime) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java b/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java
new file mode 100644
index 0000000..11a36d6
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java
@@ -0,0 +1,28 @@
+package com.tcn.exile.model.event;
+
+import com.tcn.exile.model.CallType;
+import com.tcn.exile.model.TaskData;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+
+public record TelephonyResultEvent(
+ long callSid,
+ CallType callType,
+ String orgId,
+ String internalKey,
+ String callerId,
+ String phoneNumber,
+ String poolId,
+ String recordId,
+ long clientSid,
+ String status,
+ String outcomeCategory,
+ String outcomeDetail,
+ Duration deliveryLength,
+ Duration linkbackLength,
+ Instant createTime,
+ Instant updateTime,
+ Instant startTime,
+ Instant endTime,
+ List taskData) {}
diff --git a/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java
new file mode 100644
index 0000000..5dba985
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java
@@ -0,0 +1,33 @@
+package com.tcn.exile.model.event;
+
+import com.tcn.exile.model.CallType;
+import java.time.Duration;
+import java.time.Instant;
+
+public record TransferInstanceEvent(
+ long clientSid,
+ String orgId,
+ long transferInstanceId,
+ Source source,
+ String transferType,
+ String transferResult,
+ String initiation,
+ Instant createTime,
+ Instant transferTime,
+ Instant acceptTime,
+ Instant hangupTime,
+ Instant endTime,
+ Instant updateTime,
+ Duration pendingDuration,
+ Duration externalDuration,
+ Duration fullDuration) {
+
+ public record Source(
+ long callSid,
+ CallType callType,
+ String partnerAgentId,
+ String userId,
+ String conversationId,
+ long sessionSid,
+ long agentCallSid) {}
+}
diff --git a/core/src/main/java/com/tcn/exile/models/Agent.java b/core/src/main/java/com/tcn/exile/models/Agent.java
deleted file mode 100644
index 6c1fa19..0000000
--- a/core/src/main/java/com/tcn/exile/models/Agent.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.core.annotation.Nullable;
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.validation.constraints.NotEmpty;
-
-@Serdeable
-public record Agent(
- @NotEmpty String userId,
- @Nullable String partnerAgentId,
- @NotEmpty String username,
- @Nullable String firstName,
- @Nullable String lastName,
- @Nullable Long currentSessionId,
- @Nullable AgentState agentState,
- @Nullable Boolean isLoggedIn,
- @Nullable Boolean isRecording,
- @Nullable Boolean agentIsMuted,
- @Nullable ConnectedParty connectedParty) {}
diff --git a/core/src/main/java/com/tcn/exile/models/AgentState.java b/core/src/main/java/com/tcn/exile/models/AgentState.java
deleted file mode 100644
index 403f6dd..0000000
--- a/core/src/main/java/com/tcn/exile/models/AgentState.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public enum AgentState {
- AGENT_STATE_UNAVAILABLE(0),
- AGENT_STATE_IDLE(1),
- AGENT_STATE_READY(2),
- AGENT_STATE_HUNGUP(3),
- AGENT_STATE_DESTROYED(4),
- AGENT_STATE_PEERED(5),
- AGENT_STATE_PAUSED(6),
- AGENT_STATE_WRAPUP(7),
- AGENT_STATE_PREPARING_AFTER_IDLE(8),
- AGENT_STATE_PREPARING_AFTER_WRAPUP(9),
- AGENT_STATE_PREPARING_AFTER_PAUSE(10),
- AGENT_STATE_PREPARING_AFTER_DIAL_CANCEL(11),
- AGENT_STATE_PREPARING_AFTER_PBX_REJECT(12),
- AGENT_STATE_PREPARING_AFTER_PBX_HANGUP(13),
- AGENT_STATE_PREPARING_AFTER_PBX_WAS_TAKEN(14),
- AGENT_STATE_PREPARING_AFTER_GUI_BUSY(15),
- AGENT_STATE_MANUAL_DIAL_PREPARED(16),
- AGENT_STATE_PREVIEW_DIAL_PREPARED(17),
- AGENT_STATE_MANUAL_DIAL_STARTED(18),
- AGENT_STATE_PREVIEW_DIAL_STARTED(19),
- AGENT_STATE_OUTBOUND_LOCKED(20),
- AGENT_STATE_WARM_AGENT_TRANSFER_STARTED_SOURCE(21),
- AGENT_STATE_WARM_AGENT_TRANSFER_STARTED_DESTINATION(22),
- AGENT_STATE_WARM_OUTBOUND_TRANSFER_STARTED(23),
- AGENT_STATE_WARM_OUTBOUND_TRANSFER_PEER_LOST(24),
- AGENT_STATE_PBX_POPUP_LOCKED(25),
- AGENT_STATE_PEERED_WITH_CALL_ON_HOLD(26),
- AGENT_STATE_CALLBACK_RESUMING(27),
- AGENT_STATE_GUI_BUSY(28),
- AGENT_STATE_INTERCOM(29),
- AGENT_STATE_INTERCOM_RINGING_SOURCE(30),
- AGENT_STATE_INTERCOM_RINGING_DESTINATION(31),
- AGENT_STATE_WARM_OUTBOUND_TRANSFER_OUTBOUND_LOST(32),
- AGENT_STATE_PREPARED_TO_PEER(33),
- AGENT_STATE_WARM_SKILL_TRANSFER_SOURCE_PENDING(34),
- AGENT_STATE_CALLER_TRANSFER_STARTED(35),
- AGENT_STATE_CALLER_TRANSFER_LOST_PEER(36),
- AGENT_STATE_CALLER_TRANSFER_LOST_MERGED_CALLER(37),
- AGENT_STATE_COLD_OUTBOUND_TRANSFER_STARTED(38),
- AGENT_STATE_COLD_AGENT_TRANSFER_STARTED(39),
- ;
-
- private int value;
-
- AgentState(int i) {
- value = 1;
- }
-
- public static AgentState forNumber(int value) {
- return AgentState.values()[value];
- }
-
- public int getValue() {
- return value;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/AgentStatus.java b/core/src/main/java/com/tcn/exile/models/AgentStatus.java
deleted file mode 100644
index bec32a6..0000000
--- a/core/src/main/java/com/tcn/exile/models/AgentStatus.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.annotation.Nullable;
-
-@Serdeable
-public record AgentStatus(
- @JsonProperty("user_id") String userId,
- @JsonProperty("state") AgentState agentState,
- @JsonProperty("current_session_id") @Nullable Long currentSessionId,
- @JsonProperty("connected_party") @Nullable ConnectedParty connectedParty,
- @JsonProperty("agent_is_muted") boolean agentIsMuted,
- @JsonProperty("is_recording") boolean isRecording) {}
diff --git a/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java b/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java
deleted file mode 100644
index 9bfae99..0000000
--- a/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.annotation.Nullable;
-import jakarta.validation.constraints.NotEmpty;
-
-@Serdeable
-public record AgentUpsertRequest(
- @NotEmpty String username,
- @Nullable String firstName,
- @Nullable String lastName,
- @Nullable String partnerAgentId,
- @Nullable String password) {}
diff --git a/core/src/main/java/com/tcn/exile/models/CallType.java b/core/src/main/java/com/tcn/exile/models/CallType.java
deleted file mode 100644
index e04fe5d..0000000
--- a/core/src/main/java/com/tcn/exile/models/CallType.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public enum CallType {
- inbound(0),
- outbound(1),
- preview(2),
- manual(3),
- mac(4);
-
- private final int value;
-
- CallType(int i) {
- value = i;
- }
-
- public int getValue() {
- return value;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/ConnectedParty.java b/core/src/main/java/com/tcn/exile/models/ConnectedParty.java
deleted file mode 100644
index b23be69..0000000
--- a/core/src/main/java/com/tcn/exile/models/ConnectedParty.java
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record ConnectedParty(
- @JsonProperty("call_sid") String callSid,
- @JsonProperty("call_type") CallType callType,
- @JsonProperty("inbound") boolean inbound) {}
diff --git a/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java b/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java
deleted file mode 100644
index fc39edc..0000000
--- a/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java
+++ /dev/null
@@ -1,1710 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-@Serdeable
-public class DiagnosticsResult {
- private Instant timestamp;
- private String hostname;
- private OperatingSystem operatingSystem;
- private JavaRuntime javaRuntime;
- private Hardware hardware;
- private Memory memory;
- private List storage;
- private Container container;
- private EnvironmentVariables environmentVariables;
- private SystemProperties systemProperties;
- private List hikariPoolMetrics;
- private ConfigDetails configDetails;
-
- // Getters and setters
- public Instant getTimestamp() {
- return timestamp;
- }
-
- public void setTimestamp(Instant timestamp) {
- this.timestamp = timestamp;
- }
-
- public String getHostname() {
- return hostname;
- }
-
- public void setHostname(String hostname) {
- this.hostname = hostname;
- }
-
- public OperatingSystem getOperatingSystem() {
- return operatingSystem;
- }
-
- public void setOperatingSystem(OperatingSystem operatingSystem) {
- this.operatingSystem = operatingSystem;
- }
-
- public JavaRuntime getJavaRuntime() {
- return javaRuntime;
- }
-
- public void setJavaRuntime(JavaRuntime javaRuntime) {
- this.javaRuntime = javaRuntime;
- }
-
- public Hardware getHardware() {
- return hardware;
- }
-
- public void setHardware(Hardware hardware) {
- this.hardware = hardware;
- }
-
- public Memory getMemory() {
- return memory;
- }
-
- public void setMemory(Memory memory) {
- this.memory = memory;
- }
-
- public List getStorage() {
- return storage;
- }
-
- public void setStorage(List storage) {
- this.storage = storage;
- }
-
- public Container getContainer() {
- return container;
- }
-
- public void setContainer(Container container) {
- this.container = container;
- }
-
- public EnvironmentVariables getEnvironmentVariables() {
- return environmentVariables;
- }
-
- public void setEnvironmentVariables(EnvironmentVariables environmentVariables) {
- this.environmentVariables = environmentVariables;
- }
-
- public SystemProperties getSystemProperties() {
- return systemProperties;
- }
-
- public void setSystemProperties(SystemProperties systemProperties) {
- this.systemProperties = systemProperties;
- }
-
- public List getHikariPoolMetrics() {
- return hikariPoolMetrics;
- }
-
- public void setHikariPoolMetrics(List hikariPoolMetrics) {
- this.hikariPoolMetrics = hikariPoolMetrics;
- }
-
- public ConfigDetails getConfigDetails() {
- return configDetails;
- }
-
- public void setConfigDetails(ConfigDetails configDetails) {
- this.configDetails = configDetails;
- }
-
- @Serdeable
- public static class OperatingSystem {
- private String name;
- private String version;
- private String architecture;
- private String manufacturer;
- private int availableProcessors;
- private long systemUptime;
- private double systemLoadAverage;
- private long totalPhysicalMemory;
- private long availablePhysicalMemory;
- private long totalSwapSpace;
- private long availableSwapSpace;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getVersion() {
- return version;
- }
-
- public void setVersion(String version) {
- this.version = version;
- }
-
- public String getArchitecture() {
- return architecture;
- }
-
- public void setArchitecture(String architecture) {
- this.architecture = architecture;
- }
-
- public String getManufacturer() {
- return manufacturer;
- }
-
- public void setManufacturer(String manufacturer) {
- this.manufacturer = manufacturer;
- }
-
- public int getAvailableProcessors() {
- return availableProcessors;
- }
-
- public void setAvailableProcessors(int availableProcessors) {
- this.availableProcessors = availableProcessors;
- }
-
- public long getSystemUptime() {
- return systemUptime;
- }
-
- public void setSystemUptime(long systemUptime) {
- this.systemUptime = systemUptime;
- }
-
- public double getSystemLoadAverage() {
- return systemLoadAverage;
- }
-
- public void setSystemLoadAverage(double systemLoadAverage) {
- this.systemLoadAverage = systemLoadAverage;
- }
-
- public long getTotalPhysicalMemory() {
- return totalPhysicalMemory;
- }
-
- public void setTotalPhysicalMemory(long totalPhysicalMemory) {
- this.totalPhysicalMemory = totalPhysicalMemory;
- }
-
- public long getAvailablePhysicalMemory() {
- return availablePhysicalMemory;
- }
-
- public void setAvailablePhysicalMemory(long availablePhysicalMemory) {
- this.availablePhysicalMemory = availablePhysicalMemory;
- }
-
- public long getTotalSwapSpace() {
- return totalSwapSpace;
- }
-
- public void setTotalSwapSpace(long totalSwapSpace) {
- this.totalSwapSpace = totalSwapSpace;
- }
-
- public long getAvailableSwapSpace() {
- return availableSwapSpace;
- }
-
- public void setAvailableSwapSpace(long availableSwapSpace) {
- this.availableSwapSpace = availableSwapSpace;
- }
- }
-
- @Serdeable
- public static class JavaRuntime {
- private String version;
- private String vendor;
- private String runtimeName;
- private String vmName;
- private String vmVersion;
- private String vmVendor;
- private String specificationName;
- private String specificationVersion;
- private String classPath;
- private String libraryPath;
- private List inputArguments;
- private long uptime;
- private long startTime;
- private String managementSpecVersion;
-
- public String getVersion() {
- return version;
- }
-
- public void setVersion(String version) {
- this.version = version;
- }
-
- public String getVendor() {
- return vendor;
- }
-
- public void setVendor(String vendor) {
- this.vendor = vendor;
- }
-
- public String getRuntimeName() {
- return runtimeName;
- }
-
- public void setRuntimeName(String runtimeName) {
- this.runtimeName = runtimeName;
- }
-
- public String getVmName() {
- return vmName;
- }
-
- public void setVmName(String vmName) {
- this.vmName = vmName;
- }
-
- public String getVmVersion() {
- return vmVersion;
- }
-
- public void setVmVersion(String vmVersion) {
- this.vmVersion = vmVersion;
- }
-
- public String getVmVendor() {
- return vmVendor;
- }
-
- public void setVmVendor(String vmVendor) {
- this.vmVendor = vmVendor;
- }
-
- public String getSpecificationName() {
- return specificationName;
- }
-
- public void setSpecificationName(String specificationName) {
- this.specificationName = specificationName;
- }
-
- public String getSpecificationVersion() {
- return specificationVersion;
- }
-
- public void setSpecificationVersion(String specificationVersion) {
- this.specificationVersion = specificationVersion;
- }
-
- public String getClassPath() {
- return classPath;
- }
-
- public void setClassPath(String classPath) {
- this.classPath = classPath;
- }
-
- public String getLibraryPath() {
- return libraryPath;
- }
-
- public void setLibraryPath(String libraryPath) {
- this.libraryPath = libraryPath;
- }
-
- public List getInputArguments() {
- return inputArguments;
- }
-
- public void setInputArguments(List inputArguments) {
- this.inputArguments = inputArguments;
- }
-
- public long getUptime() {
- return uptime;
- }
-
- public void setUptime(long uptime) {
- this.uptime = uptime;
- }
-
- public long getStartTime() {
- return startTime;
- }
-
- public void setStartTime(long startTime) {
- this.startTime = startTime;
- }
-
- public String getManagementSpecVersion() {
- return managementSpecVersion;
- }
-
- public void setManagementSpecVersion(String managementSpecVersion) {
- this.managementSpecVersion = managementSpecVersion;
- }
- }
-
- @Serdeable
- public static class Hardware {
- private String model;
- private String manufacturer;
- private String serialNumber;
- private String uuid;
- private Processor processor;
-
- public String getModel() {
- return model;
- }
-
- public void setModel(String model) {
- this.model = model;
- }
-
- public String getManufacturer() {
- return manufacturer;
- }
-
- public void setManufacturer(String manufacturer) {
- this.manufacturer = manufacturer;
- }
-
- public String getSerialNumber() {
- return serialNumber;
- }
-
- public void setSerialNumber(String serialNumber) {
- this.serialNumber = serialNumber;
- }
-
- public String getUuid() {
- return uuid;
- }
-
- public void setUuid(String uuid) {
- this.uuid = uuid;
- }
-
- public Processor getProcessor() {
- return processor;
- }
-
- public void setProcessor(Processor processor) {
- this.processor = processor;
- }
- }
-
- @Serdeable
- public static class Processor {
- private String name;
- private String identifier;
- private String architecture;
- private int physicalProcessorCount;
- private int logicalProcessorCount;
- private long maxFrequency;
- private boolean cpu64bit;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getIdentifier() {
- return identifier;
- }
-
- public void setIdentifier(String identifier) {
- this.identifier = identifier;
- }
-
- public String getArchitecture() {
- return architecture;
- }
-
- public void setArchitecture(String architecture) {
- this.architecture = architecture;
- }
-
- public int getPhysicalProcessorCount() {
- return physicalProcessorCount;
- }
-
- public void setPhysicalProcessorCount(int physicalProcessorCount) {
- this.physicalProcessorCount = physicalProcessorCount;
- }
-
- public int getLogicalProcessorCount() {
- return logicalProcessorCount;
- }
-
- public void setLogicalProcessorCount(int logicalProcessorCount) {
- this.logicalProcessorCount = logicalProcessorCount;
- }
-
- public long getMaxFrequency() {
- return maxFrequency;
- }
-
- public void setMaxFrequency(long maxFrequency) {
- this.maxFrequency = maxFrequency;
- }
-
- public boolean isCpu64bit() {
- return cpu64bit;
- }
-
- public void setCpu64bit(boolean cpu64bit) {
- this.cpu64bit = cpu64bit;
- }
- }
-
- @Serdeable
- public static class Memory {
- private long heapMemoryUsed;
- private long heapMemoryMax;
- private long heapMemoryCommitted;
- private long nonHeapMemoryUsed;
- private long nonHeapMemoryMax;
- private long nonHeapMemoryCommitted;
- private List memoryPools;
-
- public long getHeapMemoryUsed() {
- return heapMemoryUsed;
- }
-
- public void setHeapMemoryUsed(long heapMemoryUsed) {
- this.heapMemoryUsed = heapMemoryUsed;
- }
-
- public long getHeapMemoryMax() {
- return heapMemoryMax;
- }
-
- public void setHeapMemoryMax(long heapMemoryMax) {
- this.heapMemoryMax = heapMemoryMax;
- }
-
- public long getHeapMemoryCommitted() {
- return heapMemoryCommitted;
- }
-
- public void setHeapMemoryCommitted(long heapMemoryCommitted) {
- this.heapMemoryCommitted = heapMemoryCommitted;
- }
-
- public long getNonHeapMemoryUsed() {
- return nonHeapMemoryUsed;
- }
-
- public void setNonHeapMemoryUsed(long nonHeapMemoryUsed) {
- this.nonHeapMemoryUsed = nonHeapMemoryUsed;
- }
-
- public long getNonHeapMemoryMax() {
- return nonHeapMemoryMax;
- }
-
- public void setNonHeapMemoryMax(long nonHeapMemoryMax) {
- this.nonHeapMemoryMax = nonHeapMemoryMax;
- }
-
- public long getNonHeapMemoryCommitted() {
- return nonHeapMemoryCommitted;
- }
-
- public void setNonHeapMemoryCommitted(long nonHeapMemoryCommitted) {
- this.nonHeapMemoryCommitted = nonHeapMemoryCommitted;
- }
-
- public List getMemoryPools() {
- return memoryPools;
- }
-
- public void setMemoryPools(List memoryPools) {
- this.memoryPools = memoryPools;
- }
- }
-
- @Serdeable
- public static class MemoryPool {
- private String name;
- private String type;
- private long used;
- private long max;
- private long committed;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getType() {
- return type;
- }
-
- public void setType(String type) {
- this.type = type;
- }
-
- public long getUsed() {
- return used;
- }
-
- public void setUsed(long used) {
- this.used = used;
- }
-
- public long getMax() {
- return max;
- }
-
- public void setMax(long max) {
- this.max = max;
- }
-
- public long getCommitted() {
- return committed;
- }
-
- public void setCommitted(long committed) {
- this.committed = committed;
- }
- }
-
- @Serdeable
- public static class Storage {
- private String name;
- private String type;
- private String model;
- private String serialNumber;
- private long size;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getType() {
- return type;
- }
-
- public void setType(String type) {
- this.type = type;
- }
-
- public String getModel() {
- return model;
- }
-
- public void setModel(String model) {
- this.model = model;
- }
-
- public String getSerialNumber() {
- return serialNumber;
- }
-
- public void setSerialNumber(String serialNumber) {
- this.serialNumber = serialNumber;
- }
-
- public long getSize() {
- return size;
- }
-
- public void setSize(long size) {
- this.size = size;
- }
- }
-
- @Serdeable
- public static class Container {
- private boolean isContainer;
- private String containerType;
- private String containerId;
- private String containerName;
- private String imageName;
- private Map resourceLimits;
-
- public boolean isContainer() {
- return isContainer;
- }
-
- public void setContainer(boolean isContainer) {
- this.isContainer = isContainer;
- }
-
- public String getContainerType() {
- return containerType;
- }
-
- public void setContainerType(String containerType) {
- this.containerType = containerType;
- }
-
- public String getContainerId() {
- return containerId;
- }
-
- public void setContainerId(String containerId) {
- this.containerId = containerId;
- }
-
- public String getContainerName() {
- return containerName;
- }
-
- public void setContainerName(String containerName) {
- this.containerName = containerName;
- }
-
- public String getImageName() {
- return imageName;
- }
-
- public void setImageName(String imageName) {
- this.imageName = imageName;
- }
-
- public Map getResourceLimits() {
- return resourceLimits;
- }
-
- public void setResourceLimits(Map resourceLimits) {
- this.resourceLimits = resourceLimits;
- }
- }
-
- @Serdeable
- public static class EnvironmentVariables {
- private String language;
- private String path;
- private String hostname;
- private String lcAll;
- private String javaHome;
- private String javaVersion;
- private String lang;
- private String home;
-
- public String getLanguage() {
- return language;
- }
-
- public void setLanguage(String language) {
- this.language = language;
- }
-
- public String getPath() {
- return path;
- }
-
- public void setPath(String path) {
- this.path = path;
- }
-
- public String getHostname() {
- return hostname;
- }
-
- public void setHostname(String hostname) {
- this.hostname = hostname;
- }
-
- public String getLcAll() {
- return lcAll;
- }
-
- public void setLcAll(String lcAll) {
- this.lcAll = lcAll;
- }
-
- public String getJavaHome() {
- return javaHome;
- }
-
- public void setJavaHome(String javaHome) {
- this.javaHome = javaHome;
- }
-
- public String getJavaVersion() {
- return javaVersion;
- }
-
- public void setJavaVersion(String javaVersion) {
- this.javaVersion = javaVersion;
- }
-
- public String getLang() {
- return lang;
- }
-
- public void setLang(String lang) {
- this.lang = lang;
- }
-
- public String getHome() {
- return home;
- }
-
- public void setHome(String home) {
- this.home = home;
- }
- }
-
- @Serdeable
- public static class SystemProperties {
- private String javaSpecificationVersion;
- private String javaSpecificationVendor;
- private String javaSpecificationName;
- private String javaSpecificationMaintenanceVersion;
- private String javaVersion;
- private String javaVersionDate;
- private String javaVendor;
- private String javaVendorVersion;
- private String javaVendorUrl;
- private String javaVendorUrlBug;
- private String javaRuntimeName;
- private String javaRuntimeVersion;
- private String javaHome;
- private String javaClassPath;
- private String javaLibraryPath;
- private String javaClassVersion;
- private String javaVmName;
- private String javaVmVersion;
- private String javaVmVendor;
- private String javaVmInfo;
- private String javaVmSpecificationVersion;
- private String javaVmSpecificationVendor;
- private String javaVmSpecificationName;
- private String javaVmCompressedOopsMode;
- private String osName;
- private String osVersion;
- private String osArch;
- private String userName;
- private String userHome;
- private String userDir;
- private String userTimezone;
- private String userCountry;
- private String userLanguage;
- private String fileSeparator;
- private String pathSeparator;
- private String lineSeparator;
- private String fileEncoding;
- private String nativeEncoding;
- private String sunJnuEncoding;
- private String sunArchDataModel;
- private String sunJavaLauncher;
- private String sunBootLibraryPath;
- private String sunJavaCommand;
- private String sunCpuEndian;
- private String sunManagementCompiler;
- private String sunIoUnicodeEncoding;
- private String jdkDebug;
- private String javaIoTmpdir;
- private String env;
- private String micronautClassloaderLogging;
- private String ioNettyAllocatorMaxOrder;
- private String ioNettyProcessId;
- private String ioNettyMachineId;
- private String comZaxxerHikariPoolNumber;
-
- public String getJavaSpecificationVersion() {
- return javaSpecificationVersion;
- }
-
- public void setJavaSpecificationVersion(String javaSpecificationVersion) {
- this.javaSpecificationVersion = javaSpecificationVersion;
- }
-
- public String getJavaSpecificationVendor() {
- return javaSpecificationVendor;
- }
-
- public void setJavaSpecificationVendor(String javaSpecificationVendor) {
- this.javaSpecificationVendor = javaSpecificationVendor;
- }
-
- public String getJavaSpecificationName() {
- return javaSpecificationName;
- }
-
- public void setJavaSpecificationName(String javaSpecificationName) {
- this.javaSpecificationName = javaSpecificationName;
- }
-
- public String getJavaSpecificationMaintenanceVersion() {
- return javaSpecificationMaintenanceVersion;
- }
-
- public void setJavaSpecificationMaintenanceVersion(String javaSpecificationMaintenanceVersion) {
- this.javaSpecificationMaintenanceVersion = javaSpecificationMaintenanceVersion;
- }
-
- public String getJavaVersion() {
- return javaVersion;
- }
-
- public void setJavaVersion(String javaVersion) {
- this.javaVersion = javaVersion;
- }
-
- public String getJavaVersionDate() {
- return javaVersionDate;
- }
-
- public void setJavaVersionDate(String javaVersionDate) {
- this.javaVersionDate = javaVersionDate;
- }
-
- public String getJavaVendor() {
- return javaVendor;
- }
-
- public void setJavaVendor(String javaVendor) {
- this.javaVendor = javaVendor;
- }
-
- public String getJavaVendorVersion() {
- return javaVendorVersion;
- }
-
- public void setJavaVendorVersion(String javaVendorVersion) {
- this.javaVendorVersion = javaVendorVersion;
- }
-
- public String getJavaVendorUrl() {
- return javaVendorUrl;
- }
-
- public void setJavaVendorUrl(String javaVendorUrl) {
- this.javaVendorUrl = javaVendorUrl;
- }
-
- public String getJavaVendorUrlBug() {
- return javaVendorUrlBug;
- }
-
- public void setJavaVendorUrlBug(String javaVendorUrlBug) {
- this.javaVendorUrlBug = javaVendorUrlBug;
- }
-
- public String getJavaRuntimeName() {
- return javaRuntimeName;
- }
-
- public void setJavaRuntimeName(String javaRuntimeName) {
- this.javaRuntimeName = javaRuntimeName;
- }
-
- public String getJavaRuntimeVersion() {
- return javaRuntimeVersion;
- }
-
- public void setJavaRuntimeVersion(String javaRuntimeVersion) {
- this.javaRuntimeVersion = javaRuntimeVersion;
- }
-
- public String getJavaHome() {
- return javaHome;
- }
-
- public void setJavaHome(String javaHome) {
- this.javaHome = javaHome;
- }
-
- public String getJavaClassPath() {
- return javaClassPath;
- }
-
- public void setJavaClassPath(String javaClassPath) {
- this.javaClassPath = javaClassPath;
- }
-
- public String getJavaLibraryPath() {
- return javaLibraryPath;
- }
-
- public void setJavaLibraryPath(String javaLibraryPath) {
- this.javaLibraryPath = javaLibraryPath;
- }
-
- public String getJavaClassVersion() {
- return javaClassVersion;
- }
-
- public void setJavaClassVersion(String javaClassVersion) {
- this.javaClassVersion = javaClassVersion;
- }
-
- public String getJavaVmName() {
- return javaVmName;
- }
-
- public void setJavaVmName(String javaVmName) {
- this.javaVmName = javaVmName;
- }
-
- public String getJavaVmVersion() {
- return javaVmVersion;
- }
-
- public void setJavaVmVersion(String javaVmVersion) {
- this.javaVmVersion = javaVmVersion;
- }
-
- public String getJavaVmVendor() {
- return javaVmVendor;
- }
-
- public void setJavaVmVendor(String javaVmVendor) {
- this.javaVmVendor = javaVmVendor;
- }
-
- public String getJavaVmInfo() {
- return javaVmInfo;
- }
-
- public void setJavaVmInfo(String javaVmInfo) {
- this.javaVmInfo = javaVmInfo;
- }
-
- public String getJavaVmSpecificationVersion() {
- return javaVmSpecificationVersion;
- }
-
- public void setJavaVmSpecificationVersion(String javaVmSpecificationVersion) {
- this.javaVmSpecificationVersion = javaVmSpecificationVersion;
- }
-
- public String getJavaVmSpecificationVendor() {
- return javaVmSpecificationVendor;
- }
-
- public void setJavaVmSpecificationVendor(String javaVmSpecificationVendor) {
- this.javaVmSpecificationVendor = javaVmSpecificationVendor;
- }
-
- public String getJavaVmSpecificationName() {
- return javaVmSpecificationName;
- }
-
- public void setJavaVmSpecificationName(String javaVmSpecificationName) {
- this.javaVmSpecificationName = javaVmSpecificationName;
- }
-
- public String getJavaVmCompressedOopsMode() {
- return javaVmCompressedOopsMode;
- }
-
- public void setJavaVmCompressedOopsMode(String javaVmCompressedOopsMode) {
- this.javaVmCompressedOopsMode = javaVmCompressedOopsMode;
- }
-
- public String getOsName() {
- return osName;
- }
-
- public void setOsName(String osName) {
- this.osName = osName;
- }
-
- public String getOsVersion() {
- return osVersion;
- }
-
- public void setOsVersion(String osVersion) {
- this.osVersion = osVersion;
- }
-
- public String getOsArch() {
- return osArch;
- }
-
- public void setOsArch(String osArch) {
- this.osArch = osArch;
- }
-
- public String getUserName() {
- return userName;
- }
-
- public void setUserName(String userName) {
- this.userName = userName;
- }
-
- public String getUserHome() {
- return userHome;
- }
-
- public void setUserHome(String userHome) {
- this.userHome = userHome;
- }
-
- public String getUserDir() {
- return userDir;
- }
-
- public void setUserDir(String userDir) {
- this.userDir = userDir;
- }
-
- public String getUserTimezone() {
- return userTimezone;
- }
-
- public void setUserTimezone(String userTimezone) {
- this.userTimezone = userTimezone;
- }
-
- public String getUserCountry() {
- return userCountry;
- }
-
- public void setUserCountry(String userCountry) {
- this.userCountry = userCountry;
- }
-
- public String getUserLanguage() {
- return userLanguage;
- }
-
- public void setUserLanguage(String userLanguage) {
- this.userLanguage = userLanguage;
- }
-
- public String getFileSeparator() {
- return fileSeparator;
- }
-
- public void setFileSeparator(String fileSeparator) {
- this.fileSeparator = fileSeparator;
- }
-
- public String getPathSeparator() {
- return pathSeparator;
- }
-
- public void setPathSeparator(String pathSeparator) {
- this.pathSeparator = pathSeparator;
- }
-
- public String getLineSeparator() {
- return lineSeparator;
- }
-
- public void setLineSeparator(String lineSeparator) {
- this.lineSeparator = lineSeparator;
- }
-
- public String getFileEncoding() {
- return fileEncoding;
- }
-
- public void setFileEncoding(String fileEncoding) {
- this.fileEncoding = fileEncoding;
- }
-
- public String getNativeEncoding() {
- return nativeEncoding;
- }
-
- public void setNativeEncoding(String nativeEncoding) {
- this.nativeEncoding = nativeEncoding;
- }
-
- public String getSunJnuEncoding() {
- return sunJnuEncoding;
- }
-
- public void setSunJnuEncoding(String sunJnuEncoding) {
- this.sunJnuEncoding = sunJnuEncoding;
- }
-
- public String getSunArchDataModel() {
- return sunArchDataModel;
- }
-
- public void setSunArchDataModel(String sunArchDataModel) {
- this.sunArchDataModel = sunArchDataModel;
- }
-
- public String getSunJavaLauncher() {
- return sunJavaLauncher;
- }
-
- public void setSunJavaLauncher(String sunJavaLauncher) {
- this.sunJavaLauncher = sunJavaLauncher;
- }
-
- public String getSunBootLibraryPath() {
- return sunBootLibraryPath;
- }
-
- public void setSunBootLibraryPath(String sunBootLibraryPath) {
- this.sunBootLibraryPath = sunBootLibraryPath;
- }
-
- public String getSunJavaCommand() {
- return sunJavaCommand;
- }
-
- public void setSunJavaCommand(String sunJavaCommand) {
- this.sunJavaCommand = sunJavaCommand;
- }
-
- public String getSunCpuEndian() {
- return sunCpuEndian;
- }
-
- public void setSunCpuEndian(String sunCpuEndian) {
- this.sunCpuEndian = sunCpuEndian;
- }
-
- public String getSunManagementCompiler() {
- return sunManagementCompiler;
- }
-
- public void setSunManagementCompiler(String sunManagementCompiler) {
- this.sunManagementCompiler = sunManagementCompiler;
- }
-
- public String getSunIoUnicodeEncoding() {
- return sunIoUnicodeEncoding;
- }
-
- public void setSunIoUnicodeEncoding(String sunIoUnicodeEncoding) {
- this.sunIoUnicodeEncoding = sunIoUnicodeEncoding;
- }
-
- public String getJdkDebug() {
- return jdkDebug;
- }
-
- public void setJdkDebug(String jdkDebug) {
- this.jdkDebug = jdkDebug;
- }
-
- public String getJavaIoTmpdir() {
- return javaIoTmpdir;
- }
-
- public void setJavaIoTmpdir(String javaIoTmpdir) {
- this.javaIoTmpdir = javaIoTmpdir;
- }
-
- public String getEnv() {
- return env;
- }
-
- public void setEnv(String env) {
- this.env = env;
- }
-
- public String getMicronautClassloaderLogging() {
- return micronautClassloaderLogging;
- }
-
- public void setMicronautClassloaderLogging(String micronautClassloaderLogging) {
- this.micronautClassloaderLogging = micronautClassloaderLogging;
- }
-
- public String getIoNettyAllocatorMaxOrder() {
- return ioNettyAllocatorMaxOrder;
- }
-
- public void setIoNettyAllocatorMaxOrder(String ioNettyAllocatorMaxOrder) {
- this.ioNettyAllocatorMaxOrder = ioNettyAllocatorMaxOrder;
- }
-
- public String getIoNettyProcessId() {
- return ioNettyProcessId;
- }
-
- public void setIoNettyProcessId(String ioNettyProcessId) {
- this.ioNettyProcessId = ioNettyProcessId;
- }
-
- public String getIoNettyMachineId() {
- return ioNettyMachineId;
- }
-
- public void setIoNettyMachineId(String ioNettyMachineId) {
- this.ioNettyMachineId = ioNettyMachineId;
- }
-
- public String getComZaxxerHikariPoolNumber() {
- return comZaxxerHikariPoolNumber;
- }
-
- public void setComZaxxerHikariPoolNumber(String comZaxxerHikariPoolNumber) {
- this.comZaxxerHikariPoolNumber = comZaxxerHikariPoolNumber;
- }
- }
-
- @Serdeable
- public static class HikariPoolMetrics {
- private String poolName;
- private int activeConnections;
- private int idleConnections;
- private int totalConnections;
- private int threadsAwaitingConnection;
- private PoolConfig poolConfig;
- private Map extendedMetrics;
-
- public String getPoolName() {
- return poolName;
- }
-
- public void setPoolName(String poolName) {
- this.poolName = poolName;
- }
-
- public int getActiveConnections() {
- return activeConnections;
- }
-
- public void setActiveConnections(int activeConnections) {
- this.activeConnections = activeConnections;
- }
-
- public int getIdleConnections() {
- return idleConnections;
- }
-
- public void setIdleConnections(int idleConnections) {
- this.idleConnections = idleConnections;
- }
-
- public int getTotalConnections() {
- return totalConnections;
- }
-
- public void setTotalConnections(int totalConnections) {
- this.totalConnections = totalConnections;
- }
-
- public int getThreadsAwaitingConnection() {
- return threadsAwaitingConnection;
- }
-
- public void setThreadsAwaitingConnection(int threadsAwaitingConnection) {
- this.threadsAwaitingConnection = threadsAwaitingConnection;
- }
-
- public PoolConfig getPoolConfig() {
- return poolConfig;
- }
-
- public void setPoolConfig(PoolConfig poolConfig) {
- this.poolConfig = poolConfig;
- }
-
- public Map getExtendedMetrics() {
- return extendedMetrics;
- }
-
- public void setExtendedMetrics(Map extendedMetrics) {
- this.extendedMetrics = extendedMetrics;
- }
-
- @Serdeable
- public static class PoolConfig {
- private String poolName;
- private long connectionTimeout;
- private long validationTimeout;
- private long idleTimeout;
- private long maxLifetime;
- private int minimumIdle;
- private int maximumPoolSize;
- private long leakDetectionThreshold;
- private String jdbcUrl;
- private String username;
-
- public String getPoolName() {
- return poolName;
- }
-
- public void setPoolName(String poolName) {
- this.poolName = poolName;
- }
-
- public long getConnectionTimeout() {
- return connectionTimeout;
- }
-
- public void setConnectionTimeout(long connectionTimeout) {
- this.connectionTimeout = connectionTimeout;
- }
-
- public long getValidationTimeout() {
- return validationTimeout;
- }
-
- public void setValidationTimeout(long validationTimeout) {
- this.validationTimeout = validationTimeout;
- }
-
- public long getIdleTimeout() {
- return idleTimeout;
- }
-
- public void setIdleTimeout(long idleTimeout) {
- this.idleTimeout = idleTimeout;
- }
-
- public long getMaxLifetime() {
- return maxLifetime;
- }
-
- public void setMaxLifetime(long maxLifetime) {
- this.maxLifetime = maxLifetime;
- }
-
- public int getMinimumIdle() {
- return minimumIdle;
- }
-
- public void setMinimumIdle(int minimumIdle) {
- this.minimumIdle = minimumIdle;
- }
-
- public int getMaximumPoolSize() {
- return maximumPoolSize;
- }
-
- public void setMaximumPoolSize(int maximumPoolSize) {
- this.maximumPoolSize = maximumPoolSize;
- }
-
- public long getLeakDetectionThreshold() {
- return leakDetectionThreshold;
- }
-
- public void setLeakDetectionThreshold(long leakDetectionThreshold) {
- this.leakDetectionThreshold = leakDetectionThreshold;
- }
-
- public String getJdbcUrl() {
- return jdbcUrl;
- }
-
- public void setJdbcUrl(String jdbcUrl) {
- this.jdbcUrl = jdbcUrl;
- }
-
- public String getUsername() {
- return username;
- }
-
- public void setUsername(String username) {
- this.username = username;
- }
- }
- }
-
- @Serdeable
- public static class ConfigDetails {
- private String apiEndpoint;
- private String certificateName;
- private String certificateDescription;
-
- public String getApiEndpoint() {
- return apiEndpoint;
- }
-
- public void setApiEndpoint(String apiEndpoint) {
- this.apiEndpoint = apiEndpoint;
- }
-
- public String getCertificateName() {
- return certificateName;
- }
-
- public void setCertificateName(String certificateName) {
- this.certificateName = certificateName;
- }
-
- public String getCertificateDescription() {
- return certificateDescription;
- }
-
- public void setCertificateDescription(String certificateDescription) {
- this.certificateDescription = certificateDescription;
- }
- }
-
- // Convert from protobuf DiagnosticsResult to our Serdeable model
- public static DiagnosticsResult fromProto(
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult proto) {
- DiagnosticsResult result = new DiagnosticsResult();
-
- // Set basic fields
- result.setTimestamp(
- Instant.ofEpochSecond(proto.getTimestamp().getSeconds(), proto.getTimestamp().getNanos()));
- result.setHostname(proto.getHostname());
-
- // Convert OperatingSystem
- if (proto.hasOperatingSystem()) {
- OperatingSystem os = new OperatingSystem();
- os.setName(proto.getOperatingSystem().getName());
- os.setVersion(proto.getOperatingSystem().getVersion());
- os.setArchitecture(proto.getOperatingSystem().getArchitecture());
- os.setManufacturer(proto.getOperatingSystem().getManufacturer());
- os.setAvailableProcessors(proto.getOperatingSystem().getAvailableProcessors());
- os.setSystemUptime(proto.getOperatingSystem().getSystemUptime());
- os.setSystemLoadAverage(proto.getOperatingSystem().getSystemLoadAverage());
- os.setTotalPhysicalMemory(proto.getOperatingSystem().getTotalPhysicalMemory());
- os.setAvailablePhysicalMemory(proto.getOperatingSystem().getAvailablePhysicalMemory());
- os.setTotalSwapSpace(proto.getOperatingSystem().getTotalSwapSpace());
- os.setAvailableSwapSpace(proto.getOperatingSystem().getAvailableSwapSpace());
- result.setOperatingSystem(os);
- }
-
- // Convert JavaRuntime
- if (proto.hasJavaRuntime()) {
- JavaRuntime runtime = new JavaRuntime();
- runtime.setVersion(proto.getJavaRuntime().getVersion());
- runtime.setVendor(proto.getJavaRuntime().getVendor());
- runtime.setRuntimeName(proto.getJavaRuntime().getRuntimeName());
- runtime.setVmName(proto.getJavaRuntime().getVmName());
- runtime.setVmVersion(proto.getJavaRuntime().getVmVersion());
- runtime.setVmVendor(proto.getJavaRuntime().getVmVendor());
- runtime.setSpecificationName(proto.getJavaRuntime().getSpecificationName());
- runtime.setSpecificationVersion(proto.getJavaRuntime().getSpecificationVersion());
- runtime.setClassPath(proto.getJavaRuntime().getClassPath());
- runtime.setLibraryPath(proto.getJavaRuntime().getLibraryPath());
- runtime.setInputArguments(new ArrayList<>(proto.getJavaRuntime().getInputArgumentsList()));
- runtime.setUptime(proto.getJavaRuntime().getUptime());
- runtime.setStartTime(proto.getJavaRuntime().getStartTime());
- runtime.setManagementSpecVersion(proto.getJavaRuntime().getManagementSpecVersion());
- result.setJavaRuntime(runtime);
- }
-
- // Convert Hardware
- if (proto.hasHardware()) {
- Hardware hardware = new Hardware();
- hardware.setModel(proto.getHardware().getModel());
- hardware.setManufacturer(proto.getHardware().getManufacturer());
- hardware.setSerialNumber(proto.getHardware().getSerialNumber());
- hardware.setUuid(proto.getHardware().getUuid());
-
- if (proto.getHardware().hasProcessor()) {
- Processor processor = new Processor();
- processor.setName(proto.getHardware().getProcessor().getName());
- processor.setIdentifier(proto.getHardware().getProcessor().getIdentifier());
- processor.setArchitecture(proto.getHardware().getProcessor().getArchitecture());
- processor.setPhysicalProcessorCount(
- proto.getHardware().getProcessor().getPhysicalProcessorCount());
- processor.setLogicalProcessorCount(
- proto.getHardware().getProcessor().getLogicalProcessorCount());
- processor.setMaxFrequency(proto.getHardware().getProcessor().getMaxFrequency());
- processor.setCpu64bit(proto.getHardware().getProcessor().getCpu64Bit());
- hardware.setProcessor(processor);
- }
-
- result.setHardware(hardware);
- }
-
- // Convert Memory
- if (proto.hasMemory()) {
- Memory memory = new Memory();
- memory.setHeapMemoryUsed(proto.getMemory().getHeapMemoryUsed());
- memory.setHeapMemoryMax(proto.getMemory().getHeapMemoryMax());
- memory.setHeapMemoryCommitted(proto.getMemory().getHeapMemoryCommitted());
- memory.setNonHeapMemoryUsed(proto.getMemory().getNonHeapMemoryUsed());
- memory.setNonHeapMemoryMax(proto.getMemory().getNonHeapMemoryMax());
- memory.setNonHeapMemoryCommitted(proto.getMemory().getNonHeapMemoryCommitted());
-
- List pools = new ArrayList<>();
- for (var protoPool : proto.getMemory().getMemoryPoolsList()) {
- MemoryPool pool = new MemoryPool();
- pool.setName(protoPool.getName());
- pool.setType(protoPool.getType());
- pool.setUsed(protoPool.getUsed());
- pool.setMax(protoPool.getMax());
- pool.setCommitted(protoPool.getCommitted());
- pools.add(pool);
- }
- memory.setMemoryPools(pools);
-
- result.setMemory(memory);
- }
-
- // Convert Storage
- List storageList = new ArrayList<>();
- for (var protoStorage : proto.getStorageList()) {
- Storage storage = new Storage();
- storage.setName(protoStorage.getName());
- storage.setType(protoStorage.getType());
- storage.setModel(protoStorage.getModel());
- storage.setSerialNumber(protoStorage.getSerialNumber());
- storage.setSize(protoStorage.getSize());
- storageList.add(storage);
- }
- result.setStorage(storageList);
-
- // Convert Container
- if (proto.hasContainer()) {
- Container container = new Container();
- container.setContainer(proto.getContainer().getIsContainer());
- container.setContainerType(proto.getContainer().getContainerType());
- container.setContainerId(proto.getContainer().getContainerId());
- container.setContainerName(proto.getContainer().getContainerName());
- container.setImageName(proto.getContainer().getImageName());
- container.setResourceLimits(new HashMap<>(proto.getContainer().getResourceLimitsMap()));
- result.setContainer(container);
- }
-
- // Convert EnvironmentVariables
- if (proto.hasEnvironmentVariables()) {
- EnvironmentVariables env = new EnvironmentVariables();
- env.setLanguage(proto.getEnvironmentVariables().getLanguage());
- env.setPath(proto.getEnvironmentVariables().getPath());
- env.setHostname(proto.getEnvironmentVariables().getHostname());
- env.setLcAll(proto.getEnvironmentVariables().getLcAll());
- env.setJavaHome(proto.getEnvironmentVariables().getJavaHome());
- env.setJavaVersion(proto.getEnvironmentVariables().getJavaVersion());
- env.setLang(proto.getEnvironmentVariables().getLang());
- env.setHome(proto.getEnvironmentVariables().getHome());
- result.setEnvironmentVariables(env);
- }
-
- // Convert SystemProperties
- if (proto.hasSystemProperties()) {
- SystemProperties props = new SystemProperties();
- props.setJavaSpecificationVersion(proto.getSystemProperties().getJavaSpecificationVersion());
- props.setJavaSpecificationVendor(proto.getSystemProperties().getJavaSpecificationVendor());
- props.setJavaSpecificationName(proto.getSystemProperties().getJavaSpecificationName());
- props.setJavaSpecificationMaintenanceVersion(
- proto.getSystemProperties().getJavaSpecificationMaintenanceVersion());
- props.setJavaVersion(proto.getSystemProperties().getJavaVersion());
- props.setJavaVersionDate(proto.getSystemProperties().getJavaVersionDate());
- props.setJavaVendor(proto.getSystemProperties().getJavaVendor());
- props.setJavaVendorVersion(proto.getSystemProperties().getJavaVendorVersion());
- props.setJavaVendorUrl(proto.getSystemProperties().getJavaVendorUrl());
- props.setJavaVendorUrlBug(proto.getSystemProperties().getJavaVendorUrlBug());
- props.setJavaRuntimeName(proto.getSystemProperties().getJavaRuntimeName());
- props.setJavaRuntimeVersion(proto.getSystemProperties().getJavaRuntimeVersion());
- props.setJavaHome(proto.getSystemProperties().getJavaHome());
- props.setJavaClassPath(proto.getSystemProperties().getJavaClassPath());
- props.setJavaLibraryPath(proto.getSystemProperties().getJavaLibraryPath());
- props.setJavaClassVersion(proto.getSystemProperties().getJavaClassVersion());
- props.setJavaVmName(proto.getSystemProperties().getJavaVmName());
- props.setJavaVmVersion(proto.getSystemProperties().getJavaVmVersion());
- props.setJavaVmVendor(proto.getSystemProperties().getJavaVmVendor());
- props.setJavaVmInfo(proto.getSystemProperties().getJavaVmInfo());
- props.setJavaVmSpecificationVersion(
- proto.getSystemProperties().getJavaVmSpecificationVersion());
- props.setJavaVmSpecificationVendor(
- proto.getSystemProperties().getJavaVmSpecificationVendor());
- props.setJavaVmSpecificationName(proto.getSystemProperties().getJavaVmSpecificationName());
- props.setJavaVmCompressedOopsMode(proto.getSystemProperties().getJavaVmCompressedOopsMode());
- props.setOsName(proto.getSystemProperties().getOsName());
- props.setOsVersion(proto.getSystemProperties().getOsVersion());
- props.setOsArch(proto.getSystemProperties().getOsArch());
- props.setUserName(proto.getSystemProperties().getUserName());
- props.setUserHome(proto.getSystemProperties().getUserHome());
- props.setUserDir(proto.getSystemProperties().getUserDir());
- props.setUserTimezone(proto.getSystemProperties().getUserTimezone());
- props.setUserCountry(proto.getSystemProperties().getUserCountry());
- props.setUserLanguage(proto.getSystemProperties().getUserLanguage());
- props.setFileSeparator(proto.getSystemProperties().getFileSeparator());
- props.setPathSeparator(proto.getSystemProperties().getPathSeparator());
- props.setLineSeparator(proto.getSystemProperties().getLineSeparator());
- props.setFileEncoding(proto.getSystemProperties().getFileEncoding());
- props.setNativeEncoding(proto.getSystemProperties().getNativeEncoding());
- props.setSunJnuEncoding(proto.getSystemProperties().getSunJnuEncoding());
- props.setSunArchDataModel(proto.getSystemProperties().getSunArchDataModel());
- props.setSunJavaLauncher(proto.getSystemProperties().getSunJavaLauncher());
- props.setSunBootLibraryPath(proto.getSystemProperties().getSunBootLibraryPath());
- props.setSunJavaCommand(proto.getSystemProperties().getSunJavaCommand());
- props.setSunCpuEndian(proto.getSystemProperties().getSunCpuEndian());
- props.setSunManagementCompiler(proto.getSystemProperties().getSunManagementCompiler());
- props.setSunIoUnicodeEncoding(proto.getSystemProperties().getSunIoUnicodeEncoding());
- props.setJdkDebug(proto.getSystemProperties().getJdkDebug());
- props.setJavaIoTmpdir(proto.getSystemProperties().getJavaIoTmpdir());
- props.setEnv(proto.getSystemProperties().getEnv());
- props.setMicronautClassloaderLogging(
- proto.getSystemProperties().getMicronautClassloaderLogging());
- props.setIoNettyAllocatorMaxOrder(proto.getSystemProperties().getIoNettyAllocatorMaxOrder());
- props.setIoNettyProcessId(proto.getSystemProperties().getIoNettyProcessId());
- props.setIoNettyMachineId(proto.getSystemProperties().getIoNettyMachineId());
- props.setComZaxxerHikariPoolNumber(
- proto.getSystemProperties().getComZaxxerHikariPoolNumber());
- result.setSystemProperties(props);
- }
-
- // Convert HikariPoolMetrics
- List hikariPools = new ArrayList<>();
- for (var protoPool : proto.getHikariPoolMetricsList()) {
- HikariPoolMetrics pool = new HikariPoolMetrics();
- pool.setPoolName(protoPool.getPoolName());
- pool.setActiveConnections(protoPool.getActiveConnections());
- pool.setIdleConnections(protoPool.getIdleConnections());
- pool.setTotalConnections(protoPool.getTotalConnections());
- pool.setThreadsAwaitingConnection(protoPool.getThreadsAwaitingConnection());
- pool.setExtendedMetrics(new HashMap<>(protoPool.getExtendedMetricsMap()));
-
- if (protoPool.hasPoolConfig()) {
- HikariPoolMetrics.PoolConfig config = new HikariPoolMetrics.PoolConfig();
- config.setPoolName(protoPool.getPoolConfig().getPoolName());
- config.setConnectionTimeout(protoPool.getPoolConfig().getConnectionTimeout());
- config.setValidationTimeout(protoPool.getPoolConfig().getValidationTimeout());
- config.setIdleTimeout(protoPool.getPoolConfig().getIdleTimeout());
- config.setMaxLifetime(protoPool.getPoolConfig().getMaxLifetime());
- config.setMinimumIdle(protoPool.getPoolConfig().getMinimumIdle());
- config.setMaximumPoolSize(protoPool.getPoolConfig().getMaximumPoolSize());
- config.setLeakDetectionThreshold(protoPool.getPoolConfig().getLeakDetectionThreshold());
- config.setJdbcUrl(protoPool.getPoolConfig().getJdbcUrl());
- config.setUsername(protoPool.getPoolConfig().getUsername());
- pool.setPoolConfig(config);
- }
-
- hikariPools.add(pool);
- }
- result.setHikariPoolMetrics(hikariPools);
-
- // Convert ConfigDetails
- if (proto.hasConfigDetails()) {
- ConfigDetails config = new ConfigDetails();
- config.setApiEndpoint(proto.getConfigDetails().getApiEndpoint());
- config.setCertificateName(proto.getConfigDetails().getCertificateName());
- config.setCertificateDescription(proto.getConfigDetails().getCertificateDescription());
- result.setConfigDetails(config);
- }
-
- return result;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/DialRequest.java b/core/src/main/java/com/tcn/exile/models/DialRequest.java
deleted file mode 100644
index 9028af7..0000000
--- a/core/src/main/java/com/tcn/exile/models/DialRequest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.annotation.Nullable;
-import jakarta.validation.constraints.NotEmpty;
-
-@Serdeable
-public record DialRequest(
- @NotEmpty String phoneNumber,
- @Nullable String callerId,
- @Nullable String recordId,
- @Nullable String poolId,
- @Nullable String rulesetName,
- @Nullable Boolean skipComplianceChecks,
- @Nullable Boolean recordCall) {}
diff --git a/core/src/main/java/com/tcn/exile/models/DialResponse.java b/core/src/main/java/com/tcn/exile/models/DialResponse.java
deleted file mode 100644
index 2d5ba01..0000000
--- a/core/src/main/java/com/tcn/exile/models/DialResponse.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record DialResponse(
- @JsonProperty("phone_number") String phoneNumber,
- @JsonProperty("caller_id") String callerId,
- @JsonProperty("call_sid") String callSid,
- @JsonProperty("call_type") CallType callType,
- @JsonProperty("org_id") String orgId,
- @JsonProperty("partner_agent_id") String partnerAgentId) {}
diff --git a/core/src/main/java/com/tcn/exile/models/GateClientState.java b/core/src/main/java/com/tcn/exile/models/GateClientState.java
deleted file mode 100644
index 3c468f5..0000000
--- a/core/src/main/java/com/tcn/exile/models/GateClientState.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public enum GateClientState {
- RUNNING,
- STOPPED;
-}
diff --git a/core/src/main/java/com/tcn/exile/models/GateClientStatus.java b/core/src/main/java/com/tcn/exile/models/GateClientStatus.java
deleted file mode 100644
index 0ddc5ce..0000000
--- a/core/src/main/java/com/tcn/exile/models/GateClientStatus.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record GateClientStatus(GateClientState status) {}
diff --git a/core/src/main/java/com/tcn/exile/models/LookupType.java b/core/src/main/java/com/tcn/exile/models/LookupType.java
deleted file mode 100644
index 6f61347..0000000
--- a/core/src/main/java/com/tcn/exile/models/LookupType.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-public enum LookupType {
- RECORD(0),
- PHONE(1),
- CLIENT_REF(1),
- ACCOUNT_NUMBER(2);
-
- private final int type;
-
- LookupType(int i) {
- this.type = i;
- }
-
- public int getType() {
- return type;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/ManualDialResult.java b/core/src/main/java/com/tcn/exile/models/ManualDialResult.java
deleted file mode 100644
index e53a93c..0000000
--- a/core/src/main/java/com/tcn/exile/models/ManualDialResult.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import build.buf.gen.tcnapi.exile.gate.v2.DialResponse;
-import java.util.Objects;
-
-public record ManualDialResult(
- String phoneNumber,
- String callerId,
- long callSid,
- String callType,
- String orgId,
- String partnerAgentId) {
- public ManualDialResult {
-
- Objects.nonNull(phoneNumber);
- Objects.nonNull(callerId);
- Objects.nonNull(callSid);
-
- Objects.nonNull(callType);
- Objects.nonNull(orgId);
- Objects.nonNull(partnerAgentId);
- }
-
- public static ManualDialResult fromProto(DialResponse result) {
- if (result == null) {
- throw new IllegalArgumentException("result cannot be null");
- }
- String callType = "";
-
- switch (result.getCallType()) {
- case CALL_TYPE_INBOUND:
- callType = "inbound";
- break;
- case CALL_TYPE_OUTBOUND:
- callType = "outbound";
- break;
- case CALL_TYPE_MANUAL:
- callType = "manual";
- break;
- case CALL_TYPE_MAC:
- callType = "outbound";
- break;
- case CALL_TYPE_PREVIEW:
- callType = "outbound";
- break;
- default:
- throw new IllegalArgumentException("Invalid call type: " + result.getCallType());
- }
- try {
- return new ManualDialResult(
- result.getPhoneNumber(),
- result.getCallerId(),
- Long.parseLong(result.getCallSid()),
- callType,
- result.getOrgId(),
- result.getPartnerAgentId());
- } catch (NumberFormatException e) {
- throw new IllegalArgumentException("Invalid call_sid format: " + result.getCallSid(), e);
- }
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/OrgInfo.java b/core/src/main/java/com/tcn/exile/models/OrgInfo.java
deleted file mode 100644
index c3101e3..0000000
--- a/core/src/main/java/com/tcn/exile/models/OrgInfo.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import build.buf.gen.tcnapi.exile.gate.v2.GetOrganizationInfoResponse;
-import jakarta.validation.constraints.NotEmpty;
-
-public record OrgInfo(@NotEmpty String orgId, String orgName) {
- public OrgInfo(GetOrganizationInfoResponse response) {
- this(response.getOrgName(), response.getOrgName());
- }
-
- public String getName() {
- return orgName;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java b/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java
deleted file mode 100644
index 02dae39..0000000
--- a/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.context.event.ApplicationEvent;
-import io.micronaut.serde.annotation.Serdeable;
-import java.util.Objects;
-
-@Serdeable
-public class PluginConfigEvent extends ApplicationEvent {
- private String orgId;
-
- public String getOrgId() {
- return orgId;
- }
-
- public String getOrgName() {
- return orgName;
- }
-
- public String getConfigurationName() {
- return configurationName;
- }
-
- public String getConfigurationPayload() {
- return configurationPayload;
- }
-
- private String orgName;
- private String configurationName;
- private String configurationPayload;
- private boolean unconfigured = Boolean.TRUE;
-
- public PluginConfigEvent setOrgId(String orgId) {
- this.orgId = orgId;
- return this;
- }
-
- public boolean isUnconfigured() {
- return this.unconfigured;
- }
-
- public PluginConfigEvent setUnconfigured(boolean unconfigured) {
- this.unconfigured = unconfigured;
- return this;
- }
-
- public PluginConfigEvent setOrgName(String orgName) {
- this.orgName = orgName;
- return this;
- }
-
- public PluginConfigEvent setConfigurationName(String configurationName) {
- this.configurationName = configurationName;
- return this;
- }
-
- public PluginConfigEvent setConfigurationPayload(String configurationPayload) {
- this.configurationPayload = configurationPayload;
- return this;
- }
-
- /**
- * Constructs a prototypical Event.
- *
- * @param source The object on which the Event initially occurred.
- * @throws IllegalArgumentException if source is null.
- */
- public PluginConfigEvent(Object source) {
- super(source);
- }
-
- @Override
- public String toString() {
- return "PluginConfigEvent{"
- + "orgId='"
- + orgId
- + '\''
- + ", orgName='"
- + orgName
- + '\''
- + ", configurationName='"
- + configurationName
- + '\''
- + ", configurationPayload='"
- + configurationPayload
- + '\''
- + ", unconfigured="
- + unconfigured
- + '}';
- }
-
- public boolean equals(PluginConfigEvent o) {
- if (this == o) return true;
- if (o == null || getClass() != o.getClass()) return false;
- return Objects.equals(orgId, (o).orgId)
- && Objects.equals(orgName, (o).orgName)
- && Objects.equals(configurationName, (o).configurationName)
- && Objects.equals(configurationPayload, (o).configurationPayload)
- && Objects.equals(unconfigured, (o).unconfigured);
- }
-
- public int hashCode() {
- return Objects.hash(orgId, orgName, configurationName, configurationPayload, unconfigured);
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/Pool.java b/core/src/main/java/com/tcn/exile/models/Pool.java
deleted file mode 100644
index 463dc7a..0000000
--- a/core/src/main/java/com/tcn/exile/models/Pool.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.core.annotation.Nullable;
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record Pool(
- String satiPoolId, String displayName, PoolStatus status, @Nullable Integer size) {}
diff --git a/core/src/main/java/com/tcn/exile/models/PoolStatus.java b/core/src/main/java/com/tcn/exile/models/PoolStatus.java
deleted file mode 100644
index 02c37a9..0000000
--- a/core/src/main/java/com/tcn/exile/models/PoolStatus.java
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-public enum PoolStatus {
- READY(0),
- NOT_READY(1),
- NOT_AVAILABLE(1),
- BUSY(2);
-
- private final int status;
-
- PoolStatus(int i) {
- this.status = i;
- }
-
- public int getStatus() {
- return status;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/Record.java b/core/src/main/java/com/tcn/exile/models/Record.java
deleted file mode 100644
index 4bb01d0..0000000
--- a/core/src/main/java/com/tcn/exile/models/Record.java
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.core.annotation.Nullable;
-import jakarta.validation.constraints.NotNull;
-
-public record Record(
- @NotNull String recordId,
- @NotNull String satiParentId,
- @Nullable String satiPoolId,
- @Nullable String jsonRecordPayload) {}
diff --git a/core/src/main/java/com/tcn/exile/models/RecordingResponse.java b/core/src/main/java/com/tcn/exile/models/RecordingResponse.java
deleted file mode 100644
index c015594..0000000
--- a/core/src/main/java/com/tcn/exile/models/RecordingResponse.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record RecordingResponse(@JsonProperty("recording") boolean recording) {}
diff --git a/core/src/main/java/com/tcn/exile/models/ScrubList.java b/core/src/main/java/com/tcn/exile/models/ScrubList.java
deleted file mode 100644
index 76f0833..0000000
--- a/core/src/main/java/com/tcn/exile/models/ScrubList.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record ScrubList(String scrubListId, boolean readOnly, ScrubListType contentType) {}
diff --git a/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java b/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java
deleted file mode 100644
index 44dea38..0000000
--- a/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import io.micronaut.core.annotation.Introspected;
-import io.micronaut.core.annotation.Nullable;
-import io.micronaut.serde.annotation.Serdeable;
-import jakarta.validation.constraints.NotEmpty;
-import java.util.Date;
-
-@Serdeable
-@Introspected
-public record ScrubListEntry(
- @NotEmpty @JsonProperty("content") String content,
- @JsonProperty("expiration_date") @Nullable Date expirationDate,
- @Nullable @JsonProperty("notes") String notes,
- @Nullable @JsonProperty("country_code") String countryCode) {}
diff --git a/core/src/main/java/com/tcn/exile/models/ScrubListType.java b/core/src/main/java/com/tcn/exile/models/ScrubListType.java
deleted file mode 100644
index a70ed82..0000000
--- a/core/src/main/java/com/tcn/exile/models/ScrubListType.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-public enum ScrubListType {
- phone_number(0),
- email(1),
- sms(2),
- other(3);
-
- private int value;
-
- public int getValue() {
- return value;
- }
-
- ScrubListType(int value) {
- this.value = value;
- }
-
- public static ScrubListType create(int value) {
- if (value >= ScrubListType.values().length) return other;
- return ScrubListType.values()[value];
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/SetAgentState.java b/core/src/main/java/com/tcn/exile/models/SetAgentState.java
deleted file mode 100644
index c45af02..0000000
--- a/core/src/main/java/com/tcn/exile/models/SetAgentState.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-public enum SetAgentState {
- // UNAVALIABLE(0),
- // IDLE(1),
- READY(2),
- HUNGUP(3),
- // DESTROYED(4),
- // PEERED(5),
- PAUSED(6),
- WRAPUP(7),
-// PREPARING_AFTER_IDLE(8),
-// PREPARING_AFTER_WRAPUP(9),
-// PREPARING_AFTER_PAUSE(10),
-// PREPARING_AFTER_DIAL_CANCEL(11),
-// PREPARING_AFTER_PBX_REJECT(12),
-// PREPARING_AFTER_PBX_HANGUP(13),
-// PREPARING_AFTER_PBX_WAS_TAKEN(14),
-// PREPARING_AFTER_GUI_BUSY(15),
-// MANUAL_DIAL_PREPARED(16),
-// PREVIEW_DIAL_PREPARED(17),
-// MANUAL_DIAL_STARTED(18),
-// PREVIEW_DIAL_STARTED(19),
-// OUTBOUND_LOCKED(20),
-// WARM_AGENT_TRANSFER_STARTED_SOURCE(21),
-// WARM_AGENT_TRANSFER_STARTED_DESTINATION(22),
-// WARM_OUTBOUND_TRANSFER_STARTED(23),
-// WARM_OUTBOUND_TRANSFER_PEER_LOST(24),
-// PBX_POPUP_LOCKED(25),
-// PEERED_WITH_CALL_ON_HOLD(26),
-// CALLBACK_RESUMING(27),
-// GUI_BUSY(28),
-// INTERCOM(29),
-// INTERCOM_RINGING_SOURCE(30),
-// INTERCOM_RINGING_DESTINATION(31),
-// WARM_OUTBOUND_TRANSFER_OUTBOUND_LOST(32),
-// PREPARED_TO_PEER(33),
-// WARM_SKILL_TRANSFER_SOURCE_PENDING(34),
-// CALLER_TRANSFER_STARTED(35),
-// CALLER_TRANSFER_LOST_PEER(36),
-// CALLER_TRANSFER_LOST_MERGED_CALLER(37),
-// COLD_OUTBOUND_TRANSFER_STARTED(38),
-// COLD_AGENT_TRANSFER_STARTED(39),
-;
- private int value;
-
- SetAgentState(int i) {
- value = i;
- }
-
- public int getValue() {
- return value;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java b/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java
deleted file mode 100644
index 373ca0f..0000000
--- a/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-
-@Serdeable
-public record SetAgentStatusResponse() {}
diff --git a/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java b/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java
deleted file mode 100644
index 9e427a6..0000000
--- a/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java
+++ /dev/null
@@ -1,174 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.models;
-
-import io.micronaut.serde.annotation.Serdeable;
-import java.time.Instant;
-import java.util.List;
-import java.util.Map;
-
-@Serdeable
-public class TenantLogsResult {
- private List logGroups;
- private String nextPageToken;
-
- public List getLogGroups() {
- return logGroups;
- }
-
- public void setLogGroups(List logGroups) {
- this.logGroups = logGroups;
- }
-
- public String getNextPageToken() {
- return nextPageToken;
- }
-
- public void setNextPageToken(String nextPageToken) {
- this.nextPageToken = nextPageToken;
- }
-
- @Serdeable
- public static class LogGroup {
- private String name;
- private List logs;
- private TimeRange timeRange;
- private Map logLevels;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public List getLogs() {
- return logs;
- }
-
- public void setLogs(List logs) {
- this.logs = logs;
- }
-
- public TimeRange getTimeRange() {
- return timeRange;
- }
-
- public void setTimeRange(TimeRange timeRange) {
- this.timeRange = timeRange;
- }
-
- public Map getLogLevels() {
- return logLevels;
- }
-
- public void setLogLevels(Map logLevels) {
- this.logLevels = logLevels;
- }
-
- @Serdeable
- public static class TimeRange {
- private Instant startTime;
- private Instant endTime;
-
- public Instant getStartTime() {
- return startTime;
- }
-
- public void setStartTime(Instant startTime) {
- this.startTime = startTime;
- }
-
- public Instant getEndTime() {
- return endTime;
- }
-
- public void setEndTime(Instant endTime) {
- this.endTime = endTime;
- }
- }
-
- @Serdeable
- public enum LogLevel {
- DEBUG,
- INFO,
- WARNING,
- ERROR,
- FATAL
- }
- }
-
- // Convert from protobuf ListTenantLogsResult to our Serdeable model
- public static TenantLogsResult fromProto(
- build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult proto) {
- TenantLogsResult result = new TenantLogsResult();
- result.setNextPageToken(proto.getNextPageToken());
-
- java.util.List logGroups = new java.util.ArrayList<>();
- for (var protoLogGroup : proto.getLogGroupsList()) {
- LogGroup logGroup = new LogGroup();
- logGroup.setName(protoLogGroup.getName());
- logGroup.setLogs(protoLogGroup.getLogsList());
-
- if (protoLogGroup.hasTimeRange()) {
- LogGroup.TimeRange timeRange = new LogGroup.TimeRange();
- timeRange.setStartTime(
- Instant.ofEpochSecond(
- protoLogGroup.getTimeRange().getStartTime().getSeconds(),
- protoLogGroup.getTimeRange().getStartTime().getNanos()));
- timeRange.setEndTime(
- Instant.ofEpochSecond(
- protoLogGroup.getTimeRange().getEndTime().getSeconds(),
- protoLogGroup.getTimeRange().getEndTime().getNanos()));
- logGroup.setTimeRange(timeRange);
- }
-
- // Convert log levels map
- Map logLevelsMap = new java.util.HashMap<>();
- for (var entry : protoLogGroup.getLogLevelsMap().entrySet()) {
- LogGroup.LogLevel level;
- switch (entry.getValue()) {
- case DEBUG:
- level = LogGroup.LogLevel.DEBUG;
- break;
- case INFO:
- level = LogGroup.LogLevel.INFO;
- break;
- case WARNING:
- level = LogGroup.LogLevel.WARNING;
- break;
- case ERROR:
- level = LogGroup.LogLevel.ERROR;
- break;
- case FATAL:
- level = LogGroup.LogLevel.FATAL;
- break;
- default:
- level = LogGroup.LogLevel.INFO;
- }
- logLevelsMap.put(entry.getKey(), level);
- }
- logGroup.setLogLevels(logLevelsMap);
-
- logGroups.add(logGroup);
- }
- result.setLogGroups(logGroups);
-
- return result;
- }
-}
diff --git a/core/src/main/java/com/tcn/exile/plugin/Job.java b/core/src/main/java/com/tcn/exile/plugin/Job.java
deleted file mode 100644
index 231658e..0000000
--- a/core/src/main/java/com/tcn/exile/plugin/Job.java
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.plugin;
-
-public interface Job extends Runnable {}
diff --git a/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java b/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java
deleted file mode 100644
index b33d5b9..0000000
--- a/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * (C) 2017-2025 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.plugin;
-
-import build.buf.gen.tcnapi.exile.gate.v2.*;
-import com.tcn.exile.gateclients.UnconfiguredException;
-import com.tcn.exile.models.PluginConfigEvent;
-
-public interface PluginInterface {
- String getName();
-
- boolean isRunning();
-
- PluginStatus getPluginStatus();
-
- /**
- * List available pools of data for interogation
- *
- * @param jobId
- * @param listPools
- * @throws UnconfiguredException
- */
- void listPools(String jobId, StreamJobsResponse.ListPoolsRequest listPools)
- throws UnconfiguredException;
-
- /**
- * Get pool status
- *
- * @param jobId
- * @param satiPoolId
- * @throws UnconfiguredException
- */
- void getPoolStatus(String jobId, StreamJobsResponse.GetPoolStatusRequest satiPoolId)
- throws UnconfiguredException;
-
- /**
- * Stream the records belonging to a pool
- *
- * @param jobId
- * @param satiPoolId
- * @throws UnconfiguredException
- */
- void getPoolRecords(String jobId, StreamJobsResponse.GetPoolRecordsRequest satiPoolId)
- throws UnconfiguredException;
-
- /**
- * Handle agent call
- *
- * @param exileAgentCall
- */
- void handleAgentCall(ExileAgentCall exileAgentCall);
-
- /**
- * Handle telephony result
- *
- * @param exileTelephonyResult
- */
- void handleTelephonyResult(ExileTelephonyResult exileTelephonyResult);
-
- /** handle task */
- void handleTask(ExileTask exileTask);
-
- /**
- * Handle agent response
- *
- * @param exileAgentResponse
- */
- void handleAgentResponse(ExileAgentResponse exileAgentResponse);
-
- void searchRecords(String jobId, StreamJobsResponse.SearchRecordsRequest searchRecords);
-
- void readFields(String jobId, StreamJobsResponse.GetRecordFieldsRequest getRecordFields);
-
- void writeFields(String jobId, StreamJobsResponse.SetRecordFieldsRequest setRecordFields);
-
- void createPayment(String jobId, StreamJobsResponse.CreatePaymentRequest createPayment);
-
- void popAccount(String jobId, StreamJobsResponse.PopAccountRequest popAccount);
-
- void info(String jobId, StreamJobsResponse.InfoRequest info);
-
- SubmitJobResultsRequest.InfoResult info();
-
- void shutdown(String jobId, StreamJobsResponse.SeppukuRequest shutdown);
-
- void logger(String jobId, StreamJobsResponse.LoggingRequest log);
-
- void executeLogic(String jobId, StreamJobsResponse.ExecuteLogicRequest executeLogic);
-
- /**
- * Run system diagnostics and collect detailed information about the system environment, JVM
- * settings, memory usage, container details, and database connections. The resulting diagnostics
- * data is submitted back to the gate service.
- *
- * @param jobId The ID of the job
- * @param diagnosticsRequest The diagnostics request details
- */
- void runDiagnostics(String jobId, StreamJobsResponse.DiagnosticsRequest diagnosticsRequest);
-
- /**
- * List tenant logs by retrieving logs from memory and formatting them into log groups. The logs
- * are retrieved from the MemoryAppender and submitted back to the gate service.
- *
- * @param jobId The ID of the job
- * @param listTenantLogsRequest The list tenant logs request details
- */
- void listTenantLogs(String jobId, StreamJobsResponse.ListTenantLogsRequest listTenantLogsRequest);
-
- /**
- * Set the log level for a specific logger dynamically. This allows changing log levels at runtime
- * without restarting the application.
- *
- * @param jobId The ID of the job
- * @param setLogLevelRequest The set log level request details
- */
- void setLogLevel(String jobId, StreamJobsResponse.SetLogLevelRequest setLogLevelRequest);
-
- void setConfig(PluginConfigEvent config);
-
- void handleTransferInstance(ExileTransferInstance exileTransferInstance);
-
- void handleCallRecording(ExileCallRecording exileCallRecording);
-}
diff --git a/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java b/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java
deleted file mode 100644
index 04ab50f..0000000
--- a/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * (C) 2017-2024 TCN Inc. All rights reserved.
- *
- * 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
- *
- * https://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.tcn.exile.plugin;
-
-import io.micronaut.serde.annotation.Serdeable;
-import java.util.Map;
-
-@Serdeable
-public record PluginStatus(
- String name,
- boolean running,
- int queueMaxSize,
- long queueCompletedJobs,
- int queueActiveCount,
- Map internalConfig,
- Map internalStatus) {}
diff --git a/core/src/main/java/com/tcn/exile/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java
new file mode 100644
index 0000000..07bfeef
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/AgentService.java
@@ -0,0 +1,151 @@
+package com.tcn.exile.service;
+
+import static com.tcn.exile.internal.ProtoConverter.*;
+
+import com.tcn.exile.internal.ProtoConverter;
+import com.tcn.exile.model.*;
+import io.grpc.ManagedChannel;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** Agent management operations. No proto types in the public API. */
+public final class AgentService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.AgentServiceGrpc.AgentServiceBlockingStub stub;
+
+ AgentService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.AgentServiceGrpc.newBlockingStub(channel);
+ }
+
+ public Agent getAgentByPartnerId(String partnerAgentId) {
+ var resp =
+ stub.getAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ return toAgent(resp.getAgent());
+ }
+
+ public Agent getAgentByUserId(String userId) {
+ var resp =
+ stub.getAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder()
+ .setUserId(userId)
+ .build());
+ return toAgent(resp.getAgent());
+ }
+
+ public Page listAgents(
+ Boolean loggedIn,
+ AgentState state,
+ boolean includeRecordingStatus,
+ String pageToken,
+ int pageSize) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.ListAgentsRequest.newBuilder()
+ .setIncludeRecordingStatus(includeRecordingStatus)
+ .setPageSize(pageSize);
+ if (loggedIn != null) req.setLoggedIn(loggedIn);
+ if (state != null) req.setState(fromAgentState(state));
+ if (pageToken != null) req.setPageToken(pageToken);
+ var resp = stub.listAgents(req.build());
+ return new Page<>(
+ resp.getAgentsList().stream().map(ProtoConverter::toAgent).collect(Collectors.toList()),
+ resp.getNextPageToken());
+ }
+
+ public Agent upsertAgent(
+ String partnerAgentId, String username, String firstName, String lastName) {
+ var resp =
+ stub.upsertAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.UpsertAgentRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setUsername(username)
+ .setFirstName(firstName)
+ .setLastName(lastName)
+ .build());
+ return toAgent(resp.getAgent());
+ }
+
+ public void setAgentCredentials(String partnerAgentId, String password) {
+ stub.setAgentCredentials(
+ build.buf.gen.tcnapi.exile.gate.v3.SetAgentCredentialsRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setPassword(password)
+ .build());
+ }
+
+ public void updateAgentStatus(String partnerAgentId, AgentState newState, String reason) {
+ stub.updateAgentStatus(
+ build.buf.gen.tcnapi.exile.gate.v3.UpdateAgentStatusRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setNewState(fromAgentState(newState))
+ .setReason(reason != null ? reason : "")
+ .build());
+ }
+
+ public void muteAgent(String partnerAgentId) {
+ stub.muteAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.MuteAgentRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ }
+
+ public void unmuteAgent(String partnerAgentId) {
+ stub.unmuteAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.UnmuteAgentRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ }
+
+ public void addAgentCallResponse(
+ String partnerAgentId,
+ long callSid,
+ CallType callType,
+ String sessionId,
+ String key,
+ String value) {
+ stub.addAgentCallResponse(
+ build.buf.gen.tcnapi.exile.gate.v3.AddAgentCallResponseRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setCallSid(callSid)
+ .setCallType(
+ build.buf.gen.tcnapi.exile.gate.v3.CallType.valueOf("CALL_TYPE_" + callType.name()))
+ .setCurrentSessionId(sessionId)
+ .setKey(key)
+ .setValue(value)
+ .build());
+ }
+
+ public List listSkills() {
+ var resp =
+ stub.listSkills(build.buf.gen.tcnapi.exile.gate.v3.ListSkillsRequest.getDefaultInstance());
+ return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList());
+ }
+
+ public List listAgentSkills(String partnerAgentId) {
+ var resp =
+ stub.listAgentSkills(
+ build.buf.gen.tcnapi.exile.gate.v3.ListAgentSkillsRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList());
+ }
+
+ public void assignAgentSkill(String partnerAgentId, String skillId, long proficiency) {
+ stub.assignAgentSkill(
+ build.buf.gen.tcnapi.exile.gate.v3.AssignAgentSkillRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setSkillId(skillId)
+ .setProficiency(proficiency)
+ .build());
+ }
+
+ public void unassignAgentSkill(String partnerAgentId, String skillId) {
+ stub.unassignAgentSkill(
+ build.buf.gen.tcnapi.exile.gate.v3.UnassignAgentSkillRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setSkillId(skillId)
+ .build());
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/CallService.java b/core/src/main/java/com/tcn/exile/service/CallService.java
new file mode 100644
index 0000000..f663bab
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/CallService.java
@@ -0,0 +1,140 @@
+package com.tcn.exile.service;
+
+import com.tcn.exile.model.CallType;
+import io.grpc.ManagedChannel;
+import java.util.Map;
+
+/** Call control operations. No proto types in the public API. */
+public final class CallService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.CallServiceBlockingStub stub;
+
+ CallService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.newBlockingStub(channel);
+ }
+
+ public record DialResult(
+ String phoneNumber,
+ String callerId,
+ long callSid,
+ CallType callType,
+ String orgId,
+ String partnerAgentId,
+ boolean attempted,
+ String status) {}
+
+ public DialResult dial(
+ String partnerAgentId,
+ String phoneNumber,
+ String callerId,
+ String poolId,
+ String recordId,
+ String rulesetName,
+ Boolean skipCompliance,
+ Boolean recordCall) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.DialRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setPhoneNumber(phoneNumber);
+ if (callerId != null) req.setCallerId(callerId);
+ if (poolId != null) req.setPoolId(poolId);
+ if (recordId != null) req.setRecordId(recordId);
+ if (rulesetName != null) req.setRulesetName(rulesetName);
+ if (skipCompliance != null) req.setSkipComplianceChecks(skipCompliance);
+ if (recordCall != null) req.setRecordCall(recordCall);
+ var resp = stub.dial(req.build());
+ return new DialResult(
+ resp.getPhoneNumber(),
+ resp.getCallerId(),
+ resp.getCallSid(),
+ com.tcn.exile.internal.ProtoConverter.toCallType(resp.getCallType()),
+ resp.getOrgId(),
+ resp.getPartnerAgentId(),
+ resp.getAttempted(),
+ resp.getStatus());
+ }
+
+ public void transfer(
+ String partnerAgentId,
+ String kind,
+ String action,
+ String destAgentId,
+ String destPhone,
+ Map destSkills) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId);
+ req.setKind(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferKind.valueOf(
+ "TRANSFER_KIND_" + kind));
+ req.setAction(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferAction.valueOf(
+ "TRANSFER_ACTION_" + action));
+ if (destAgentId != null) {
+ req.setAgent(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.AgentDestination.newBuilder()
+ .setPartnerAgentId(destAgentId));
+ } else if (destPhone != null) {
+ req.setOutbound(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.OutboundDestination.newBuilder()
+ .setPhoneNumber(destPhone));
+ } else if (destSkills != null) {
+ req.setQueue(
+ build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.QueueDestination.newBuilder()
+ .putAllRequiredSkills(destSkills));
+ }
+ stub.transfer(req.build());
+ }
+
+ public enum HoldTarget {
+ CALL,
+ TRANSFER_CALLER,
+ TRANSFER_AGENT
+ }
+
+ public enum HoldAction {
+ HOLD,
+ UNHOLD
+ }
+
+ public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) {
+ stub.setHoldState(
+ build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .setTarget(
+ build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.HoldTarget.valueOf(
+ "HOLD_TARGET_" + target.name()))
+ .setAction(
+ build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.HoldAction.valueOf(
+ "HOLD_ACTION_" + action.name()))
+ .build());
+ }
+
+ public void startCallRecording(String partnerAgentId) {
+ stub.startCallRecording(
+ build.buf.gen.tcnapi.exile.gate.v3.StartCallRecordingRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ }
+
+ public void stopCallRecording(String partnerAgentId) {
+ stub.stopCallRecording(
+ build.buf.gen.tcnapi.exile.gate.v3.StopCallRecordingRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build());
+ }
+
+ public boolean getRecordingStatus(String partnerAgentId) {
+ return stub.getRecordingStatus(
+ build.buf.gen.tcnapi.exile.gate.v3.GetRecordingStatusRequest.newBuilder()
+ .setPartnerAgentId(partnerAgentId)
+ .build())
+ .getIsRecording();
+ }
+
+ public java.util.List listComplianceRulesets() {
+ return stub.listComplianceRulesets(
+ build.buf.gen.tcnapi.exile.gate.v3.ListComplianceRulesetsRequest.getDefaultInstance())
+ .getRulesetNamesList();
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/ConfigService.java b/core/src/main/java/com/tcn/exile/service/ConfigService.java
new file mode 100644
index 0000000..c0b8a2c
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java
@@ -0,0 +1,52 @@
+package com.tcn.exile.service;
+
+import com.tcn.exile.internal.ProtoConverter;
+import io.grpc.ManagedChannel;
+import java.util.Map;
+
+/** Configuration and lifecycle operations. No proto types in the public API. */
+public final class ConfigService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.ConfigServiceBlockingStub stub;
+
+ ConfigService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.newBlockingStub(channel);
+ }
+
+ public record ClientConfiguration(
+ String orgId, String orgName, String configName, Map configPayload) {}
+
+ public record OrgInfo(String orgId, String orgName) {}
+
+ public ClientConfiguration getClientConfiguration() {
+ var resp =
+ stub.getClientConfiguration(
+ build.buf.gen.tcnapi.exile.gate.v3.GetClientConfigurationRequest.getDefaultInstance());
+ return new ClientConfiguration(
+ resp.getOrgId(),
+ resp.getOrgName(),
+ resp.getConfigName(),
+ ProtoConverter.structToMap(resp.getConfigPayload()));
+ }
+
+ public OrgInfo getOrganizationInfo() {
+ var resp =
+ stub.getOrganizationInfo(
+ build.buf.gen.tcnapi.exile.gate.v3.GetOrganizationInfoRequest.getDefaultInstance());
+ return new OrgInfo(resp.getOrgId(), resp.getOrgName());
+ }
+
+ public String rotateCertificate(String certificateHash) {
+ var resp =
+ stub.rotateCertificate(
+ build.buf.gen.tcnapi.exile.gate.v3.RotateCertificateRequest.newBuilder()
+ .setCertificateHash(certificateHash)
+ .build());
+ return resp.getEncodedCertificate();
+ }
+
+ public void log(String payload) {
+ stub.log(
+ build.buf.gen.tcnapi.exile.gate.v3.LogRequest.newBuilder().setPayload(payload).build());
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/JourneyService.java b/core/src/main/java/com/tcn/exile/service/JourneyService.java
new file mode 100644
index 0000000..d2580ec
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/JourneyService.java
@@ -0,0 +1,39 @@
+package com.tcn.exile.service;
+
+import com.tcn.exile.internal.ProtoConverter;
+import com.tcn.exile.model.DataRecord;
+import io.grpc.ManagedChannel;
+
+/** Journey buffer operations. No proto types in the public API. */
+public final class JourneyService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.JourneyServiceGrpc.JourneyServiceBlockingStub
+ stub;
+
+ JourneyService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.JourneyServiceGrpc.newBlockingStub(channel);
+ }
+
+ public enum JourneyBufferStatus {
+ INSERTED,
+ UPDATED,
+ IGNORED,
+ REJECTED,
+ UNSPECIFIED
+ }
+
+ public JourneyBufferStatus addRecordToJourneyBuffer(DataRecord record) {
+ var resp =
+ stub.addRecordToJourneyBuffer(
+ build.buf.gen.tcnapi.exile.gate.v3.AddRecordToJourneyBufferRequest.newBuilder()
+ .setRecord(ProtoConverter.fromRecord(record))
+ .build());
+ return switch (resp.getStatus()) {
+ case JOURNEY_BUFFER_STATUS_INSERTED -> JourneyBufferStatus.INSERTED;
+ case JOURNEY_BUFFER_STATUS_UPDATED -> JourneyBufferStatus.UPDATED;
+ case JOURNEY_BUFFER_STATUS_IGNORED -> JourneyBufferStatus.IGNORED;
+ case JOURNEY_BUFFER_STATUS_REJECTED -> JourneyBufferStatus.REJECTED;
+ default -> JourneyBufferStatus.UNSPECIFIED;
+ };
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/RecordingService.java b/core/src/main/java/com/tcn/exile/service/RecordingService.java
new file mode 100644
index 0000000..b866efb
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java
@@ -0,0 +1,94 @@
+package com.tcn.exile.service;
+
+import com.tcn.exile.internal.ProtoConverter;
+import com.tcn.exile.model.*;
+import io.grpc.ManagedChannel;
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** Voice recording search and retrieval. No proto types in the public API. */
+public final class RecordingService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.RecordingServiceBlockingStub
+ stub;
+
+ RecordingService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.newBlockingStub(channel);
+ }
+
+ public record VoiceRecording(
+ String recordingId,
+ long callSid,
+ CallType callType,
+ Duration startOffset,
+ Duration endOffset,
+ java.time.Instant startTime,
+ Duration duration,
+ String agentPhone,
+ String clientPhone,
+ String campaign,
+ List partnerAgentIds,
+ String label,
+ String value) {}
+
+ public record DownloadLinks(String downloadLink, String playbackLink) {}
+
+ public Page searchVoiceRecordings(
+ List filters, String pageToken, int pageSize) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.SearchVoiceRecordingsRequest.newBuilder()
+ .setPageSize(pageSize);
+ if (pageToken != null) req.setPageToken(pageToken);
+ for (var f : filters) req.addFilters(ProtoConverter.fromFilter(f));
+ var resp = stub.searchVoiceRecordings(req.build());
+ var recordings =
+ resp.getRecordingsList().stream()
+ .map(
+ r ->
+ new VoiceRecording(
+ r.getRecordingId(),
+ r.getCallSid(),
+ ProtoConverter.toCallType(r.getCallType()),
+ ProtoConverter.toDuration(r.getStartOffset()),
+ ProtoConverter.toDuration(r.getEndOffset()),
+ ProtoConverter.toInstant(r.getStartTime()),
+ ProtoConverter.toDuration(r.getDuration()),
+ r.getAgentPhone(),
+ r.getClientPhone(),
+ r.getCampaign(),
+ r.getPartnerAgentIdsList(),
+ r.getLabel(),
+ r.getValue()))
+ .collect(Collectors.toList());
+ return new Page<>(recordings, resp.getNextPageToken());
+ }
+
+ public DownloadLinks getDownloadLink(
+ String recordingId, Duration startOffset, Duration endOffset) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.GetDownloadLinkRequest.newBuilder()
+ .setRecordingId(recordingId);
+ if (startOffset != null) req.setStartOffset(ProtoConverter.fromDuration(startOffset));
+ if (endOffset != null) req.setEndOffset(ProtoConverter.fromDuration(endOffset));
+ var resp = stub.getDownloadLink(req.build());
+ return new DownloadLinks(resp.getDownloadLink(), resp.getPlaybackLink());
+ }
+
+ public List listSearchableFields() {
+ return stub.listSearchableFields(
+ build.buf.gen.tcnapi.exile.gate.v3.ListSearchableFieldsRequest.getDefaultInstance())
+ .getFieldsList();
+ }
+
+ public void createLabel(long callSid, CallType callType, String key, String value) {
+ stub.createLabel(
+ build.buf.gen.tcnapi.exile.gate.v3.CreateLabelRequest.newBuilder()
+ .setCallSid(callSid)
+ .setCallType(
+ build.buf.gen.tcnapi.exile.gate.v3.CallType.valueOf("CALL_TYPE_" + callType.name()))
+ .setKey(key)
+ .setValue(value)
+ .build());
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/ScrubListService.java b/core/src/main/java/com/tcn/exile/service/ScrubListService.java
new file mode 100644
index 0000000..e0fc83a
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java
@@ -0,0 +1,76 @@
+package com.tcn.exile.service;
+
+import io.grpc.ManagedChannel;
+import java.time.Instant;
+import java.util.List;
+
+/** Scrub list management. No proto types in the public API. */
+public final class ScrubListService {
+
+ private final build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.ScrubListServiceBlockingStub
+ stub;
+
+ ScrubListService(ManagedChannel channel) {
+ this.stub = build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.newBlockingStub(channel);
+ }
+
+ public record ScrubList(String scrubListId, boolean readOnly, String contentType) {}
+
+ public record ScrubListEntry(
+ String content, Instant expiration, String notes, String countryCode) {}
+
+ public List listScrubLists() {
+ return stub
+ .listScrubLists(
+ build.buf.gen.tcnapi.exile.gate.v3.ListScrubListsRequest.getDefaultInstance())
+ .getScrubListsList()
+ .stream()
+ .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), sl.getContentType().name()))
+ .toList();
+ }
+
+ public void addEntries(
+ String scrubListId, List entries, String defaultCountryCode) {
+ var req =
+ build.buf.gen.tcnapi.exile.gate.v3.AddEntriesRequest.newBuilder()
+ .setScrubListId(scrubListId);
+ if (defaultCountryCode != null) req.setDefaultCountryCode(defaultCountryCode);
+ for (var e : entries) {
+ var eb =
+ build.buf.gen.tcnapi.exile.gate.v3.ScrubListEntry.newBuilder().setContent(e.content());
+ if (e.expiration() != null) {
+ eb.setExpiration(
+ com.google.protobuf.Timestamp.newBuilder().setSeconds(e.expiration().getEpochSecond()));
+ }
+ if (e.notes() != null) eb.setNotes(e.notes());
+ if (e.countryCode() != null) eb.setCountryCode(e.countryCode());
+ req.addEntries(eb);
+ }
+ stub.addEntries(req.build());
+ }
+
+ public void updateEntry(String scrubListId, ScrubListEntry entry) {
+ var eb =
+ build.buf.gen.tcnapi.exile.gate.v3.ScrubListEntry.newBuilder().setContent(entry.content());
+ if (entry.expiration() != null) {
+ eb.setExpiration(
+ com.google.protobuf.Timestamp.newBuilder()
+ .setSeconds(entry.expiration().getEpochSecond()));
+ }
+ if (entry.notes() != null) eb.setNotes(entry.notes());
+ if (entry.countryCode() != null) eb.setCountryCode(entry.countryCode());
+ stub.updateEntry(
+ build.buf.gen.tcnapi.exile.gate.v3.UpdateEntryRequest.newBuilder()
+ .setScrubListId(scrubListId)
+ .setEntry(eb)
+ .build());
+ }
+
+ public void removeEntries(String scrubListId, List entries) {
+ stub.removeEntries(
+ build.buf.gen.tcnapi.exile.gate.v3.RemoveEntriesRequest.newBuilder()
+ .setScrubListId(scrubListId)
+ .addAllEntries(entries)
+ .build());
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/ServiceFactory.java b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java
new file mode 100644
index 0000000..15d3cb8
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java
@@ -0,0 +1,28 @@
+package com.tcn.exile.service;
+
+import io.grpc.ManagedChannel;
+
+/** Factory for creating all domain service clients. Keeps ManagedChannel out of public API. */
+public final class ServiceFactory {
+ private ServiceFactory() {}
+
+ public record Services(
+ AgentService agent,
+ CallService call,
+ RecordingService recording,
+ ScrubListService scrubList,
+ ConfigService config,
+ JourneyService journey,
+ TelemetryService telemetry) {}
+
+ public static Services create(ManagedChannel channel) {
+ return new Services(
+ new AgentService(channel),
+ new CallService(channel),
+ new RecordingService(channel),
+ new ScrubListService(channel),
+ new ConfigService(channel),
+ new JourneyService(channel),
+ new TelemetryService(channel));
+ }
+}
diff --git a/core/src/main/java/com/tcn/exile/service/TelemetryService.java b/core/src/main/java/com/tcn/exile/service/TelemetryService.java
new file mode 100644
index 0000000..e865b69
--- /dev/null
+++ b/core/src/main/java/com/tcn/exile/service/TelemetryService.java
@@ -0,0 +1,161 @@
+package com.tcn.exile.service;
+
+import build.buf.gen.tcnapi.exile.gate.v3.*;
+import com.google.protobuf.Timestamp;
+import io.grpc.ManagedChannel;
+import io.opentelemetry.sdk.metrics.data.*;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+
+/** Reports client metrics and logs to the gate TelemetryService. */
+public final class TelemetryService {
+
+ private final TelemetryServiceGrpc.TelemetryServiceBlockingStub stub;
+
+ TelemetryService(ManagedChannel channel) {
+ this.stub = TelemetryServiceGrpc.newBlockingStub(channel);
+ }
+
+ /** Send a batch of OTel metric data to the gate. Returns accepted count. */
+ public int reportMetrics(String clientId, Collection metrics) {
+ var now = Instant.now();
+ var builder =
+ ReportMetricsRequest.newBuilder().setClientId(clientId).setCollectionTime(toTimestamp(now));
+
+ for (var metric : metrics) {
+ var name = metric.getName();
+ var description = metric.getDescription();
+ var unit = metric.getUnit();
+ // Resource attributes (org_id, config_name, client_id) are merged into every data point.
+ var resourceAttrs = attributesToMap(metric.getResource().getAttributes());
+
+ switch (metric.getType()) {
+ case LONG_GAUGE -> {
+ for (var point : metric.getLongGaugeData().getPoints()) {
+ var attrs = mergeAttributes(resourceAttrs, point.getAttributes());
+ builder.addDataPoints(
+ MetricDataPoint.newBuilder()
+ .setName(name)
+ .setDescription(description)
+ .setUnit(unit)
+ .setType(MetricType.METRIC_TYPE_GAUGE)
+ .putAllAttributes(attrs)
+ .setTime(toTimestamp(point.getEpochNanos()))
+ .setDoubleValue(point.getValue())
+ .build());
+ }
+ }
+ case DOUBLE_GAUGE -> {
+ for (var point : metric.getDoubleGaugeData().getPoints()) {
+ var attrs = mergeAttributes(resourceAttrs, point.getAttributes());
+ builder.addDataPoints(
+ MetricDataPoint.newBuilder()
+ .setName(name)
+ .setDescription(description)
+ .setUnit(unit)
+ .setType(MetricType.METRIC_TYPE_GAUGE)
+ .putAllAttributes(attrs)
+ .setTime(toTimestamp(point.getEpochNanos()))
+ .setDoubleValue(point.getValue())
+ .build());
+ }
+ }
+ case LONG_SUM -> {
+ for (var point : metric.getLongSumData().getPoints()) {
+ var attrs = mergeAttributes(resourceAttrs, point.getAttributes());
+ builder.addDataPoints(
+ MetricDataPoint.newBuilder()
+ .setName(name)
+ .setDescription(description)
+ .setUnit(unit)
+ .setType(MetricType.METRIC_TYPE_SUM)
+ .putAllAttributes(attrs)
+ .setTime(toTimestamp(point.getEpochNanos()))
+ .setIntValue(point.getValue())
+ .build());
+ }
+ }
+ case DOUBLE_SUM -> {
+ for (var point : metric.getDoubleSumData().getPoints()) {
+ var attrs = mergeAttributes(resourceAttrs, point.getAttributes());
+ builder.addDataPoints(
+ MetricDataPoint.newBuilder()
+ .setName(name)
+ .setDescription(description)
+ .setUnit(unit)
+ .setType(MetricType.METRIC_TYPE_SUM)
+ .putAllAttributes(attrs)
+ .setTime(toTimestamp(point.getEpochNanos()))
+ .setDoubleValue(point.getValue())
+ .build());
+ }
+ }
+ case HISTOGRAM -> {
+ for (var point : metric.getHistogramData().getPoints()) {
+ var attrs = mergeAttributes(resourceAttrs, point.getAttributes());
+ var hv =
+ HistogramValue.newBuilder()
+ .setCount(point.getCount())
+ .setSum(point.getSum())
+ .addAllBoundaries(point.getBoundaries())
+ .addAllBucketCounts(point.getCounts())
+ .setMin(point.getMin())
+ .setMax(point.getMax());
+ builder.addDataPoints(
+ MetricDataPoint.newBuilder()
+ .setName(name)
+ .setDescription(description)
+ .setUnit(unit)
+ .setType(MetricType.METRIC_TYPE_HISTOGRAM)
+ .putAllAttributes(attrs)
+ .setTime(toTimestamp(point.getEpochNanos()))
+ .setHistogramValue(hv)
+ .build());
+ }
+ }
+ default -> {} // skip unsupported types
+ }
+ }
+
+ var resp = stub.reportMetrics(builder.build());
+ return resp.getAcceptedCount();
+ }
+
+ /** Send a batch of structured log records to the gate. Returns accepted count. */
+ public int reportLogs(String clientId, List records) {
+ var builder = ReportLogsRequest.newBuilder().setClientId(clientId).addAllRecords(records);
+ var resp = stub.reportLogs(builder.build());
+ return resp.getAcceptedCount();
+ }
+
+ private static java.util.Map attributesToMap(
+ io.opentelemetry.api.common.Attributes attrs) {
+ var map = new java.util.HashMap();
+ attrs.forEach((k, v) -> map.put(k.getKey(), String.valueOf(v)));
+ return map;
+ }
+
+ /** Merge resource attributes with point attributes (point attrs take precedence). */
+ private static java.util.Map mergeAttributes(
+ java.util.Map resourceAttrs,
+ io.opentelemetry.api.common.Attributes pointAttrs) {
+ var merged = new java.util.HashMap<>(resourceAttrs);
+ pointAttrs.forEach((k, v) -> merged.put(k.getKey(), String.valueOf(v)));
+ return merged;
+ }
+
+ private static Timestamp toTimestamp(Instant instant) {
+ return Timestamp.newBuilder()
+ .setSeconds(instant.getEpochSecond())
+ .setNanos(instant.getNano())
+ .build();
+ }
+
+ private static Timestamp toTimestamp(long epochNanos) {
+ return Timestamp.newBuilder()
+ .setSeconds(epochNanos / 1_000_000_000L)
+ .setNanos((int) (epochNanos % 1_000_000_000L))
+ .build();
+ }
+}
diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties
deleted file mode 100644
index d807bb0..0000000
--- a/core/src/main/resources/application.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-#Fri Sep 20 01:51:42 UTC 2024
-micronaut.application.name=core
diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml
deleted file mode 100644
index d93fec7..0000000
--- a/core/src/main/resources/application.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-micronaut:
- application:
- name: sati
- server:
- port: 8080
-
-# Disable both health checks and the gRPC server
-endpoints:
- health:
- enabled: true
- details-visible: ANONYMOUS
- grpc:
- enabled: false
- disk-space:
- enabled: true
- jdbc:
- enabled: false
-
-# Ensure gRPC server is disabled
-grpc:
- server:
- enabled: false
- health:
- enabled: false
-
-# For health monitoring
-jackson:
- serialization:
- indentOutput: true
- writeDatesAsTimestamps: false
\ No newline at end of file
diff --git a/core/src/main/resources/logback.xml b/core/src/main/resources/logback.xml
deleted file mode 100644
index b70e42a..0000000
--- a/core/src/main/resources/logback.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
- %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
-
-
-
-
-
-
-
-
-
diff --git a/core/src/test/java/com/tcn/exile/ExileConfigTest.java b/core/src/test/java/com/tcn/exile/ExileConfigTest.java
new file mode 100644
index 0000000..3284c60
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/ExileConfigTest.java
@@ -0,0 +1,45 @@
+package com.tcn.exile;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class ExileConfigTest {
+
+ @Test
+ void builderSetsFields() {
+ var config =
+ ExileConfig.builder()
+ .rootCert("root")
+ .publicCert("pub")
+ .privateKey("key")
+ .apiHostname("gate.example.com")
+ .apiPort(8443)
+ .build();
+ assertEquals("root", config.rootCert());
+ assertEquals("pub", config.publicCert());
+ assertEquals("key", config.privateKey());
+ assertEquals("gate.example.com", config.apiHostname());
+ assertEquals(8443, config.apiPort());
+ }
+
+ @Test
+ void defaultPort() {
+ var config =
+ ExileConfig.builder()
+ .rootCert("root")
+ .publicCert("pub")
+ .privateKey("key")
+ .apiHostname("gate.example.com")
+ .build();
+ assertEquals(443, config.apiPort());
+ }
+
+ @Test
+ void nullFieldsThrow() {
+ assertThrows(NullPointerException.class, () -> ExileConfig.builder().build());
+ assertThrows(
+ NullPointerException.class,
+ () -> ExileConfig.builder().rootCert("r").publicCert("p").build());
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/StreamStatusTest.java b/core/src/test/java/com/tcn/exile/StreamStatusTest.java
new file mode 100644
index 0000000..3404b2a
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/StreamStatusTest.java
@@ -0,0 +1,49 @@
+package com.tcn.exile;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+
+class StreamStatusTest {
+
+ @Test
+ void isHealthyWhenActive() {
+ var status =
+ new StreamStatus(StreamStatus.Phase.ACTIVE, "c-1", Instant.now(), null, null, 3, 100, 2, 1);
+ assertTrue(status.isHealthy());
+ }
+
+ @Test
+ void isNotHealthyWhenReconnecting() {
+ var status =
+ new StreamStatus(
+ StreamStatus.Phase.RECONNECTING,
+ null,
+ null,
+ Instant.now(),
+ "connection refused",
+ 0,
+ 50,
+ 5,
+ 3);
+ assertFalse(status.isHealthy());
+ }
+
+ @Test
+ void isNotHealthyWhenIdle() {
+ var status = new StreamStatus(StreamStatus.Phase.IDLE, null, null, null, null, 0, 0, 0, 0);
+ assertFalse(status.isHealthy());
+ }
+
+ @Test
+ void isNotHealthyWhenClosed() {
+ var status = new StreamStatus(StreamStatus.Phase.CLOSED, null, null, null, null, 0, 100, 0, 5);
+ assertFalse(status.isHealthy());
+ }
+
+ @Test
+ void phaseEnumValues() {
+ assertEquals(6, StreamStatus.Phase.values().length);
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/internal/BackoffTest.java b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java
new file mode 100644
index 0000000..e93bb1e
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java
@@ -0,0 +1,56 @@
+package com.tcn.exile.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+
+class BackoffTest {
+
+ @Test
+ void firstAttemptReturnsZero() {
+ var b = new Backoff();
+ assertEquals(0, b.nextDelayMs());
+ }
+
+ @Test
+ void delayIncreasesExponentially() {
+ var b = new Backoff();
+ b.recordFailure();
+ long d1 = b.nextDelayMs();
+ assertTrue(d1 >= 400 && d1 <= 600, "First failure ~500ms, got " + d1);
+
+ b.recordFailure();
+ long d2 = b.nextDelayMs();
+ assertTrue(d2 >= 800 && d2 <= 1200, "Second failure ~1s, got " + d2);
+
+ b.recordFailure();
+ long d3 = b.nextDelayMs();
+ assertTrue(d3 >= 1600 && d3 <= 2400, "Third failure ~2s, got " + d3);
+ }
+
+ @Test
+ void delayCapsAtMax() {
+ var b = new Backoff();
+ for (int i = 0; i < 20; i++) b.recordFailure();
+ long d = b.nextDelayMs();
+ assertTrue(d <= 10_000, "Should cap at 10s, got " + d);
+ }
+
+ @Test
+ void resetResetsToZero() {
+ var b = new Backoff();
+ b.recordFailure();
+ b.recordFailure();
+ b.reset();
+ assertEquals(0, b.nextDelayMs());
+ }
+
+ @Test
+ void sleepDoesNotSleepOnFirstAttempt() throws InterruptedException {
+ var b = new Backoff();
+ long start = System.currentTimeMillis();
+ b.sleep();
+ long elapsed = System.currentTimeMillis() - start;
+ assertTrue(elapsed < 100, "Should not sleep on first attempt, took " + elapsed);
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java
new file mode 100644
index 0000000..cfd84a0
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java
@@ -0,0 +1,368 @@
+package com.tcn.exile.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import build.buf.gen.tcnapi.exile.gate.v3.*;
+import io.grpc.ManagedChannel;
+import io.grpc.Server;
+import io.grpc.inprocess.InProcessChannelBuilder;
+import io.grpc.inprocess.InProcessServerBuilder;
+import io.grpc.stub.StreamObserver;
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Benchmarks comparing single reused channel vs new channel per stream. Uses in-process transport
+ * to isolate the channel/stream overhead from network latency.
+ */
+class ChannelBenchmark {
+
+ private static final String SERVER_NAME = "benchmark-server";
+ private Server server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server =
+ InProcessServerBuilder.forName(SERVER_NAME)
+ .directExecutor()
+ .addService(new BenchmarkWorkerService())
+ .build()
+ .start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (server != null) {
+ server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void benchmarkReusedChannel() throws Exception {
+ int iterations = 100;
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+
+ var times = new ArrayList();
+ for (int i = 0; i < iterations; i++) {
+ long start = System.nanoTime();
+ runSingleStream(channel);
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+ long min = times.stream().mapToLong(Long::longValue).min().orElse(0);
+ long max = times.stream().mapToLong(Long::longValue).max().orElse(0);
+ long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L);
+ long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L);
+
+ System.out.println("=== REUSED CHANNEL (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.2f ms)%n", avg, avg / 1_000_000.0);
+ System.out.printf(" min: %,d ns (%.2f ms)%n", min, min / 1_000_000.0);
+ System.out.printf(" max: %,d ns (%.2f ms)%n", max, max / 1_000_000.0);
+ System.out.printf(" p50: %,d ns (%.2f ms)%n", p50, p50 / 1_000_000.0);
+ System.out.printf(" p99: %,d ns (%.2f ms)%n", p99, p99 / 1_000_000.0);
+ }
+
+ @Test
+ void benchmarkNewChannelPerStream() throws Exception {
+ int iterations = 100;
+
+ var times = new ArrayList();
+ for (int i = 0; i < iterations; i++) {
+ long start = System.nanoTime();
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ runSingleStream(channel);
+ channel.shutdownNow().awaitTermination(1, TimeUnit.SECONDS);
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+ long min = times.stream().mapToLong(Long::longValue).min().orElse(0);
+ long max = times.stream().mapToLong(Long::longValue).max().orElse(0);
+ long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L);
+ long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L);
+
+ System.out.println("=== NEW CHANNEL PER STREAM (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.2f ms)%n", avg, avg / 1_000_000.0);
+ System.out.printf(" min: %,d ns (%.2f ms)%n", min, min / 1_000_000.0);
+ System.out.printf(" max: %,d ns (%.2f ms)%n", max, max / 1_000_000.0);
+ System.out.printf(" p50: %,d ns (%.2f ms)%n", p50, p50 / 1_000_000.0);
+ System.out.printf(" p99: %,d ns (%.2f ms)%n", p99, p99 / 1_000_000.0);
+ }
+
+ @Test
+ void benchmarkConcurrentStreamsOnSingleChannel() throws Exception {
+ int concurrency = 10;
+ int streamsPerThread = 50;
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+
+ var totalStreams = new AtomicInteger(0);
+ long start = System.nanoTime();
+
+ var threads = new ArrayList();
+ for (int t = 0; t < concurrency; t++) {
+ var thread =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ for (int i = 0; i < streamsPerThread; i++) {
+ try {
+ runSingleStream(channel);
+ totalStreams.incrementAndGet();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ threads.add(thread);
+ }
+ for (var thread : threads) thread.join();
+
+ long elapsed = System.nanoTime() - start;
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ System.out.println(
+ "=== CONCURRENT STREAMS (" + concurrency + " threads x " + streamsPerThread + ") ===");
+ System.out.printf(" total streams: %d%n", totalStreams.get());
+ System.out.printf(" total time: %.2f ms%n", elapsed / 1_000_000.0);
+ System.out.printf(" per stream: %.2f ms%n", (elapsed / 1_000_000.0) / totalStreams.get());
+ System.out.printf(
+ " throughput: %.0f streams/sec%n", totalStreams.get() / (elapsed / 1_000_000_000.0));
+ }
+
+ /**
+ * Opens a bidirectional WorkStream, sends Register + Pull, receives one WorkItem response, sends
+ * an Ack, and closes. Simulates one reconnect cycle.
+ */
+ private void runSingleStream(ManagedChannel channel) throws Exception {
+ var stub = WorkerServiceGrpc.newStub(channel);
+ var latch = new CountDownLatch(1);
+ var received = new AtomicInteger(0);
+
+ var observer =
+ stub.workStream(
+ new StreamObserver() {
+ @Override
+ public void onNext(WorkResponse response) {
+ received.incrementAndGet();
+ if (response.hasRegistered()) {
+ // Got Registered — done.
+ latch.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ latch.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ latch.countDown();
+ }
+ });
+
+ // Send Register.
+ observer.onNext(
+ WorkRequest.newBuilder()
+ .setRegister(Register.newBuilder().setClientName("benchmark").setClientVersion("1.0"))
+ .build());
+
+ // Wait for Registered response.
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for Registered");
+ assertTrue(received.get() >= 1, "Expected at least 1 response");
+
+ // Close stream gracefully.
+ observer.onCompleted();
+ }
+
+ @Test
+ void benchmarkMessageThroughput() throws Exception {
+ int messageCount = 10_000;
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = WorkerServiceGrpc.newStub(channel);
+ var received = new AtomicInteger(0);
+ var allReceived = new CountDownLatch(1);
+
+ var observer =
+ stub.workStream(
+ new StreamObserver() {
+ @Override
+ public void onNext(WorkResponse response) {
+ if (received.incrementAndGet() >= messageCount + 1) { // +1 for Registered
+ allReceived.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ allReceived.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ allReceived.countDown();
+ }
+ });
+
+ // Register first.
+ observer.onNext(
+ WorkRequest.newBuilder()
+ .setRegister(Register.newBuilder().setClientName("bench").setClientVersion("1.0"))
+ .build());
+
+ // Now send Pull messages as fast as possible — server responds with WorkItem for each.
+ long start = System.nanoTime();
+ for (int i = 0; i < messageCount; i++) {
+ observer.onNext(WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(1)).build());
+ }
+
+ assertTrue(allReceived.await(10, TimeUnit.SECONDS), "Timed out waiting for all messages");
+ long elapsed = System.nanoTime() - start;
+
+ observer.onCompleted();
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ double elapsedMs = elapsed / 1_000_000.0;
+ double msgsPerSec = messageCount / (elapsed / 1_000_000_000.0);
+ double usPerMsg = (elapsed / 1_000.0) / messageCount;
+
+ System.out.println("=== MESSAGE THROUGHPUT (" + messageCount + " messages) ===");
+ System.out.printf(" total time: %.2f ms%n", elapsedMs);
+ System.out.printf(" msgs/sec: %,.0f%n", msgsPerSec);
+ System.out.printf(" us/msg: %.2f%n", usPerMsg);
+ System.out.printf(" received: %d%n", received.get());
+ }
+
+ @Test
+ void benchmarkRoundTripLatency() throws Exception {
+ int iterations = 1000;
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = WorkerServiceGrpc.newStub(channel);
+ var times = new ArrayList();
+
+ for (int i = 0; i < iterations; i++) {
+ var latch = new CountDownLatch(1);
+ var responseObserver =
+ stub.workStream(
+ new StreamObserver() {
+ boolean registered = false;
+
+ @Override
+ public void onNext(WorkResponse response) {
+ if (!registered) {
+ registered = true;
+ return; // skip Registered
+ }
+ latch.countDown(); // got the WorkItem response
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ latch.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ latch.countDown();
+ }
+ });
+
+ // Register.
+ responseObserver.onNext(
+ WorkRequest.newBuilder()
+ .setRegister(Register.newBuilder().setClientName("bench").setClientVersion("1.0"))
+ .build());
+
+ // Measure round-trip: send Pull → receive WorkItem.
+ long start = System.nanoTime();
+ responseObserver.onNext(
+ WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(1)).build());
+ latch.await(5, TimeUnit.SECONDS);
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+
+ responseObserver.onCompleted();
+ }
+
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+ long min = times.stream().mapToLong(Long::longValue).min().orElse(0);
+ long max = times.stream().mapToLong(Long::longValue).max().orElse(0);
+ long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L);
+ long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L);
+
+ System.out.println("=== ROUND-TRIP LATENCY (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0);
+ System.out.printf(" min: %,d ns (%.3f ms)%n", min, min / 1_000_000.0);
+ System.out.printf(" max: %,d ns (%.3f ms)%n", max, max / 1_000_000.0);
+ System.out.printf(" p50: %,d ns (%.3f ms)%n", p50, p50 / 1_000_000.0);
+ System.out.printf(" p99: %,d ns (%.3f ms)%n", p99, p99 / 1_000_000.0);
+ }
+
+ /** WorkerService that responds to Register with Registered and to Pull with a WorkItem. */
+ static class BenchmarkWorkerService extends WorkerServiceGrpc.WorkerServiceImplBase {
+ @Override
+ public StreamObserver workStream(StreamObserver responseObserver) {
+ return new StreamObserver<>() {
+ int seq = 0;
+
+ @Override
+ public void onNext(WorkRequest request) {
+ if (request.hasRegister()) {
+ responseObserver.onNext(
+ WorkResponse.newBuilder()
+ .setRegistered(
+ Registered.newBuilder()
+ .setClientId("bench-" + System.nanoTime())
+ .setHeartbeatInterval(
+ com.google.protobuf.Duration.newBuilder().setSeconds(30))
+ .setDefaultLease(
+ com.google.protobuf.Duration.newBuilder().setSeconds(300))
+ .setMaxInflight(20))
+ .build());
+ } else if (request.hasPull()) {
+ // Respond with a WorkItem for each Pull.
+ responseObserver.onNext(
+ WorkResponse.newBuilder()
+ .setWorkItem(
+ WorkItem.newBuilder()
+ .setWorkId("w-" + (seq++))
+ .setCategory(WorkCategory.WORK_CATEGORY_EVENT)
+ .setAttempt(1)
+ .setAgentCall(
+ build.buf.gen.tcnapi.exile.gate.v3.AgentCall.newBuilder()
+ .setCallSid(seq)
+ .setAgentCallSid(seq)))
+ .build());
+ } else if (request.hasResult() || request.hasAck()) {
+ // Accept results/acks silently.
+ responseObserver.onNext(
+ WorkResponse.newBuilder()
+ .setResultAccepted(
+ ResultAccepted.newBuilder().setWorkId(request.getResult().getWorkId()))
+ .build());
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {}
+
+ @Override
+ public void onCompleted() {
+ responseObserver.onCompleted();
+ }
+ };
+ }
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java
new file mode 100644
index 0000000..c9a5f0a
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java
@@ -0,0 +1,349 @@
+package com.tcn.exile.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import build.buf.gen.tcnapi.exile.gate.v3.*;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.Timestamp;
+import com.tcn.exile.ExileConfig;
+import io.grpc.ManagedChannel;
+import io.grpc.stub.StreamObserver;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.*;
+
+/**
+ * Live benchmarks against a real exile gate v3 deployment. Reads mTLS config from the standard sati
+ * config file. Skipped if config is not present.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class LiveBenchmark {
+
+ private static final Path CONFIG_PATH =
+ Path.of(
+ System.getProperty("user.home"),
+ "dev/tcncloud/workdir/config/com.tcn.exiles.sati.config.cfg");
+
+ private static ManagedChannel channel;
+
+ @BeforeAll
+ static void connect() throws Exception {
+ assumeTrue(
+ Files.exists(CONFIG_PATH),
+ "No sati config found at " + CONFIG_PATH + " — skipping live benchmarks");
+
+ var raw = new String(Files.readAllBytes(CONFIG_PATH), StandardCharsets.UTF_8).trim();
+ byte[] json;
+ try {
+ json = Base64.getDecoder().decode(raw);
+ } catch (IllegalArgumentException e) {
+ json = raw.getBytes(StandardCharsets.UTF_8);
+ }
+ var jsonStr = new String(json, StandardCharsets.UTF_8);
+
+ // Minimal JSON extraction for the 4 fields we need.
+ var config =
+ ExileConfig.builder()
+ .rootCert(extractJsonString(jsonStr, "ca_certificate"))
+ .publicCert(extractJsonString(jsonStr, "certificate"))
+ .privateKey(extractJsonString(jsonStr, "private_key"))
+ .apiHostname(parseHost(extractJsonString(jsonStr, "api_endpoint")))
+ .apiPort(parsePort(extractJsonString(jsonStr, "api_endpoint")))
+ .build();
+
+ channel = ChannelFactory.create(config);
+ System.out.println("Connected to " + config.apiHostname() + ":" + config.apiPort());
+ }
+
+ private static String extractJsonString(String json, String key) {
+ var search = "\"" + key + "\"";
+ int idx = json.indexOf(search);
+ if (idx < 0) return "";
+ idx = json.indexOf("\"", idx + search.length() + 1); // skip colon, find opening quote
+ if (idx < 0) return "";
+ int start = idx + 1;
+ var sb = new StringBuilder();
+ for (int i = start; i < json.length(); i++) {
+ char c = json.charAt(i);
+ if (c == '"') break;
+ if (c == '\\' && i + 1 < json.length()) {
+ i++;
+ sb.append(
+ switch (json.charAt(i)) {
+ case 'n' -> '\n';
+ case 'r' -> '\r';
+ case 't' -> '\t';
+ case '\\' -> '\\';
+ case '"' -> '"';
+ default -> json.charAt(i);
+ });
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String parseHost(String endpoint) {
+ if (endpoint.contains("://")) endpoint = endpoint.substring(endpoint.indexOf("://") + 3);
+ if (endpoint.endsWith("/")) endpoint = endpoint.substring(0, endpoint.length() - 1);
+ int colon = endpoint.lastIndexOf(':');
+ return colon > 0 ? endpoint.substring(0, colon) : endpoint;
+ }
+
+ private static int parsePort(String endpoint) {
+ if (endpoint.contains("://")) endpoint = endpoint.substring(endpoint.indexOf("://") + 3);
+ if (endpoint.endsWith("/")) endpoint = endpoint.substring(0, endpoint.length() - 1);
+ int colon = endpoint.lastIndexOf(':');
+ if (colon > 0) {
+ try {
+ return Integer.parseInt(endpoint.substring(colon + 1));
+ } catch (NumberFormatException e) {
+ /* fall through */
+ }
+ }
+ return 443;
+ }
+
+ @AfterAll
+ static void disconnect() {
+ ChannelFactory.shutdown(channel);
+ }
+
+ @Test
+ @Order(1)
+ void pingLatency() {
+ var stub = BenchmarkServiceGrpc.newBlockingStub(channel);
+ int iterations = 1_000;
+ var times = new ArrayList(iterations);
+
+ // Warmup.
+ for (int i = 0; i < 20; i++) {
+ stub.ping(PingRequest.newBuilder().build());
+ }
+
+ for (int i = 0; i < iterations; i++) {
+ long start = System.nanoTime();
+ var resp = stub.ping(PingRequest.newBuilder().setClientTime(toTimestamp(start)).build());
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ times.sort(Long::compareTo);
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+
+ System.out.println("=== LIVE PING LATENCY (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0);
+ System.out.printf(
+ " min: %,d ns (%.3f ms)%n", times.getFirst(), times.getFirst() / 1_000_000.0);
+ System.out.printf(
+ " max: %,d ns (%.3f ms)%n", times.getLast(), times.getLast() / 1_000_000.0);
+ System.out.printf(
+ " p50: %,d ns (%.3f ms)%n",
+ times.get(iterations / 2), times.get(iterations / 2) / 1_000_000.0);
+ System.out.printf(
+ " p99: %,d ns (%.3f ms)%n",
+ times.get((int) (iterations * 0.99)), times.get((int) (iterations * 0.99)) / 1_000_000.0);
+ }
+
+ @Test
+ @Order(2)
+ void pingWithPayload() {
+ var stub = BenchmarkServiceGrpc.newBlockingStub(channel);
+ int iterations = 500;
+ int payloadSize = 1024;
+ var payload = ByteString.copyFrom(new byte[payloadSize]);
+ var times = new ArrayList(iterations);
+
+ // Warmup.
+ for (int i = 0; i < 10; i++) {
+ stub.ping(PingRequest.newBuilder().setPayload(payload).build());
+ }
+
+ for (int i = 0; i < iterations; i++) {
+ long start = System.nanoTime();
+ stub.ping(
+ PingRequest.newBuilder().setClientTime(toTimestamp(start)).setPayload(payload).build());
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ times.sort(Long::compareTo);
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+
+ System.out.println("=== LIVE PING WITH 1KB PAYLOAD (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0);
+ System.out.printf(
+ " min: %,d ns (%.3f ms)%n", times.getFirst(), times.getFirst() / 1_000_000.0);
+ System.out.printf(
+ " max: %,d ns (%.3f ms)%n", times.getLast(), times.getLast() / 1_000_000.0);
+ System.out.printf(
+ " p50: %,d ns (%.3f ms)%n",
+ times.get(iterations / 2), times.get(iterations / 2) / 1_000_000.0);
+ System.out.printf(
+ " p99: %,d ns (%.3f ms)%n",
+ times.get((int) (iterations * 0.99)), times.get((int) (iterations * 0.99)) / 1_000_000.0);
+ }
+
+ @Test
+ @Order(3)
+ void streamThroughputMinimal() throws Exception {
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+ int maxMessages = 10_000;
+
+ var result = runStreamBenchmark(stub, 0, maxMessages, 0);
+
+ System.out.println("=== LIVE STREAM THROUGHPUT (" + maxMessages + " msgs, 0B payload) ===");
+ printStreamStats(result);
+ }
+
+ @Test
+ @Order(4)
+ void streamThroughputWithPayload() throws Exception {
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+ int maxMessages = 1_000;
+ int payloadSize = 1024;
+
+ var result = runStreamBenchmark(stub, payloadSize, maxMessages, 0);
+
+ System.out.println("=== LIVE STREAM THROUGHPUT (" + maxMessages + " msgs, 1KB payload) ===");
+ printStreamStats(result);
+ }
+
+ @Test
+ @Order(5)
+ void streamFlowControlled() throws Exception {
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+ int maxMessages = 10_000;
+ int batchSize = 100;
+
+ var result = runStreamBenchmark(stub, 0, maxMessages, batchSize);
+
+ System.out.println(
+ "=== LIVE FLOW-CONTROLLED (" + maxMessages + " msgs, batch=" + batchSize + ") ===");
+ printStreamStats(result);
+ }
+
+ private record StreamResult(BenchmarkStats stats, long clientReceived, long clientElapsedNs) {}
+
+ private StreamResult runStreamBenchmark(
+ BenchmarkServiceGrpc.BenchmarkServiceStub stub,
+ int payloadSize,
+ int maxMessages,
+ int batchSize)
+ throws Exception {
+
+ var received = new AtomicLong(0);
+ var statsRef = new AtomicReference();
+ var done = new CountDownLatch(1);
+ var senderRef = new AtomicReference>();
+
+ long clientStart = System.nanoTime();
+
+ var requestObserver =
+ stub.streamBenchmark(
+ new StreamObserver<>() {
+ @Override
+ public void onNext(BenchmarkResponse response) {
+ if (response.hasMessage()) {
+ long count = received.incrementAndGet();
+ if (batchSize > 0 && count % batchSize == 0) {
+ senderRef
+ .get()
+ .onNext(
+ BenchmarkRequest.newBuilder()
+ .setAck(BenchmarkAck.newBuilder().setCount(batchSize))
+ .build());
+ }
+ } else if (response.hasStats()) {
+ statsRef.set(response.getStats());
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ System.err.println("Stream error: " + t.getMessage());
+ done.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ done.countDown();
+ }
+ });
+
+ senderRef.set(requestObserver);
+
+ requestObserver.onNext(
+ BenchmarkRequest.newBuilder()
+ .setStart(
+ StartBenchmark.newBuilder()
+ .setPayloadSize(payloadSize)
+ .setMaxMessages(maxMessages)
+ .setBatchSize(batchSize))
+ .build());
+
+ assertTrue(done.await(60, TimeUnit.SECONDS), "Timed out waiting for stream benchmark");
+
+ long clientElapsed = System.nanoTime() - clientStart;
+
+ // Half-close client side and give the connection a moment to settle before the next test.
+ try {
+ requestObserver.onCompleted();
+ } catch (Exception ignored) {
+ }
+ Thread.sleep(200);
+
+ var stats = statsRef.get();
+ if (stats == null && received.get() > 0) {
+ // Server sent messages but the stats frame was lost (RST_STREAM from envoy).
+ // Build approximate stats from client-side measurements.
+ double clientSec = clientElapsed / 1_000_000_000.0;
+ stats =
+ BenchmarkStats.newBuilder()
+ .setTotalMessages(received.get())
+ .setTotalBytes(received.get() * payloadSize)
+ .setDurationMs(clientElapsed / 1_000_000)
+ .setMessagesPerSecond(received.get() / clientSec)
+ .setMegabytesPerSecond(received.get() * payloadSize / clientSec / (1024 * 1024))
+ .build();
+ System.err.println("(stats estimated from client side — server stats lost to RST_STREAM)");
+ }
+ assertNotNull(stats, "Should have received stats");
+
+ return new StreamResult(stats, received.get(), clientElapsed);
+ }
+
+ private void printStreamStats(StreamResult r) {
+ var s = r.stats();
+ double clientSeconds = r.clientElapsedNs() / 1_000_000_000.0;
+ double clientMps = r.clientReceived() / clientSeconds;
+
+ System.out.printf(" server msgs sent: %,d%n", s.getTotalMessages());
+ System.out.printf(" client msgs received: %,d%n", r.clientReceived());
+ System.out.printf(" server duration: %,d ms%n", s.getDurationMs());
+ System.out.printf(" client duration: %.0f ms%n", r.clientElapsedNs() / 1_000_000.0);
+ System.out.printf(" server msgs/sec: %,.0f%n", s.getMessagesPerSecond());
+ System.out.printf(" client msgs/sec: %,.0f%n", clientMps);
+ if (s.getTotalBytes() > 0) {
+ System.out.printf(" server MB/sec: %.2f%n", s.getMegabytesPerSecond());
+ System.out.printf(" total bytes: %,d%n", s.getTotalBytes());
+ }
+ }
+
+ private static Timestamp toTimestamp(long nanos) {
+ return Timestamp.newBuilder()
+ .setSeconds(nanos / 1_000_000_000L)
+ .setNanos((int) (nanos % 1_000_000_000L))
+ .build();
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java
new file mode 100644
index 0000000..a9d7f82
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java
@@ -0,0 +1,409 @@
+package com.tcn.exile.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.tcn.exile.model.*;
+import com.tcn.exile.model.event.*;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class ProtoConverterTest {
+
+ // ---- Duration ----
+
+ @Test
+ void durationRoundTrip() {
+ var java = Duration.ofSeconds(90, 500_000_000);
+ var proto = ProtoConverter.fromDuration(java);
+ assertEquals(90, proto.getSeconds());
+ assertEquals(500_000_000, proto.getNanos());
+ assertEquals(java, ProtoConverter.toDuration(proto));
+ }
+
+ @Test
+ void durationNullReturnsDefault() {
+ var proto = ProtoConverter.fromDuration(null);
+ assertEquals(0, proto.getSeconds());
+ assertEquals(Duration.ZERO, ProtoConverter.toDuration(null));
+ }
+
+ @Test
+ void durationDefaultInstanceReturnsZero() {
+ assertEquals(
+ Duration.ZERO,
+ ProtoConverter.toDuration(com.google.protobuf.Duration.getDefaultInstance()));
+ }
+
+ // ---- Timestamp ----
+
+ @Test
+ void instantRoundTrip() {
+ var java = Instant.parse("2026-01-15T10:30:00Z");
+ var proto = ProtoConverter.fromInstant(java);
+ assertEquals(java.getEpochSecond(), proto.getSeconds());
+ assertEquals(java, ProtoConverter.toInstant(proto));
+ }
+
+ @Test
+ void instantNullReturnsNull() {
+ assertNull(ProtoConverter.toInstant(null));
+ var proto = ProtoConverter.fromInstant(null);
+ assertEquals(0, proto.getSeconds());
+ }
+
+ // ---- CallType ----
+
+ @Test
+ void callTypeMapping() {
+ assertEquals(
+ CallType.INBOUND,
+ ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND));
+ assertEquals(
+ CallType.OUTBOUND,
+ ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_OUTBOUND));
+ assertEquals(
+ CallType.PREVIEW,
+ ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_PREVIEW));
+ assertEquals(
+ CallType.MANUAL,
+ ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MANUAL));
+ assertEquals(
+ CallType.MAC,
+ ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MAC));
+ assertEquals(
+ CallType.UNSPECIFIED,
+ ProtoConverter.toCallType(
+ build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_UNSPECIFIED));
+ }
+
+ // ---- AgentState ----
+
+ @Test
+ void agentStateRoundTrip() {
+ var proto = build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_READY;
+ var java = ProtoConverter.toAgentState(proto);
+ assertEquals(AgentState.READY, java);
+ assertEquals(proto, ProtoConverter.fromAgentState(java));
+ }
+
+ @Test
+ void agentStateUnknownReturnsUnspecified() {
+ assertEquals(
+ AgentState.UNSPECIFIED,
+ ProtoConverter.toAgentState(
+ build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED));
+ }
+
+ // ---- Pool ----
+
+ @Test
+ void poolRoundTrip() {
+ var java = new Pool("P-1", "Test Pool", Pool.PoolStatus.READY, 42);
+ var proto = ProtoConverter.fromPool(java);
+ assertEquals("P-1", proto.getPoolId());
+ assertEquals("Test Pool", proto.getDescription());
+ assertEquals(42, proto.getRecordCount());
+ assertEquals(
+ build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus());
+
+ var back = ProtoConverter.toPool(proto);
+ assertEquals(java, back);
+ }
+
+ @Test
+ void poolAllStatuses() {
+ for (var status : Pool.PoolStatus.values()) {
+ var java = new Pool("P-1", "desc", status, 0);
+ var back = ProtoConverter.toPool(ProtoConverter.fromPool(java));
+ assertEquals(status, back.status());
+ }
+ }
+
+ // ---- Record ----
+
+ @Test
+ void dataRecordRoundTrip() {
+ var java = new DataRecord("P-1", "R-1", Map.of("name", "John", "age", 30.0));
+ var proto = ProtoConverter.fromRecord(java);
+ assertEquals("P-1", proto.getPoolId());
+ assertEquals("R-1", proto.getRecordId());
+ assertTrue(proto.hasPayload());
+
+ var back = ProtoConverter.toRecord(proto);
+ assertEquals("P-1", back.poolId());
+ assertEquals("R-1", back.recordId());
+ assertEquals("John", back.payload().get("name"));
+ assertEquals(30.0, back.payload().get("age"));
+ }
+
+ // ---- Field ----
+
+ @Test
+ void fieldRoundTrip() {
+ var java = new Field("fname", "John", "P-1", "R-1");
+ var proto = ProtoConverter.fromField(java);
+ var back = ProtoConverter.toField(proto);
+ assertEquals(java, back);
+ }
+
+ // ---- Filter ----
+
+ @Test
+ void filterRoundTrip() {
+ for (var op : Filter.Operator.values()) {
+ var java = new Filter("field1", op, "value1");
+ var proto = ProtoConverter.fromFilter(java);
+ var back = ProtoConverter.toFilter(proto);
+ assertEquals(java, back);
+ }
+ }
+
+ // ---- Struct / Map ----
+
+ @Test
+ void structMapRoundTrip() {
+ var map = Map.of("string", (Object) "hello", "number", 42.0, "bool", true);
+ var struct = ProtoConverter.mapToStruct(map);
+ var back = ProtoConverter.structToMap(struct);
+ assertEquals("hello", back.get("string"));
+ assertEquals(42.0, back.get("number"));
+ assertEquals(true, back.get("bool"));
+ }
+
+ @Test
+ void structMapHandlesNull() {
+ var map = ProtoConverter.structToMap(null);
+ assertTrue(map.isEmpty());
+ var struct = ProtoConverter.mapToStruct(null);
+ assertEquals(com.google.protobuf.Struct.getDefaultInstance(), struct);
+ }
+
+ @Test
+ void structMapHandlesNestedStructs() {
+ var nested = Map.of("inner", (Object) "value");
+ var map = Map.of("outer", (Object) nested);
+ var struct = ProtoConverter.mapToStruct(map);
+ var back = ProtoConverter.structToMap(struct);
+ @SuppressWarnings("unchecked")
+ var innerBack = (Map) back.get("outer");
+ assertEquals("value", innerBack.get("inner"));
+ }
+
+ @Test
+ void structMapHandlesLists() {
+ var list = List.of("a", "b", "c");
+ var map = Map.of("items", (Object) list);
+ var struct = ProtoConverter.mapToStruct(map);
+ var back = ProtoConverter.structToMap(struct);
+ @SuppressWarnings("unchecked")
+ var listBack = (List) back.get("items");
+ assertEquals(3, listBack.size());
+ assertEquals("a", listBack.get(0));
+ }
+
+ @Test
+ void valueToObjectHandlesNull() {
+ assertNull(ProtoConverter.valueToObject(null));
+ var nullValue =
+ com.google.protobuf.Value.newBuilder()
+ .setNullValue(com.google.protobuf.NullValue.NULL_VALUE)
+ .build();
+ assertNull(ProtoConverter.valueToObject(nullValue));
+ }
+
+ // ---- TaskData ----
+
+ @Test
+ void taskDataConversion() {
+ var protoList =
+ List.of(
+ build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder()
+ .setKey("pool_id")
+ .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1").build())
+ .build(),
+ build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder()
+ .setKey("count")
+ .setValue(com.google.protobuf.Value.newBuilder().setNumberValue(5.0).build())
+ .build());
+ var java = ProtoConverter.toTaskData(protoList);
+ assertEquals(2, java.size());
+ assertEquals("pool_id", java.get(0).key());
+ assertEquals("P-1", java.get(0).value());
+ assertEquals("count", java.get(1).key());
+ assertEquals(5.0, java.get(1).value());
+ }
+
+ // ---- Agent ----
+
+ @Test
+ void agentConversion() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.Agent.newBuilder()
+ .setUserId("U-1")
+ .setOrgId("O-1")
+ .setFirstName("John")
+ .setLastName("Doe")
+ .setUsername("jdoe")
+ .setPartnerAgentId("PA-1")
+ .setCurrentSessionId("S-1")
+ .setAgentState(build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_READY)
+ .setIsLoggedIn(true)
+ .setIsMuted(false)
+ .setIsRecording(true)
+ .setConnectedParty(
+ build.buf.gen.tcnapi.exile.gate.v3.Agent.ConnectedParty.newBuilder()
+ .setCallSid(42)
+ .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND)
+ .setIsInbound(true))
+ .build();
+
+ var java = ProtoConverter.toAgent(proto);
+ assertEquals("U-1", java.userId());
+ assertEquals("O-1", java.orgId());
+ assertEquals("John", java.firstName());
+ assertEquals("jdoe", java.username());
+ assertEquals(AgentState.READY, java.state());
+ assertTrue(java.loggedIn());
+ assertFalse(java.muted());
+ assertTrue(java.recording());
+ assertTrue(java.connectedParty().isPresent());
+ assertEquals(42, java.connectedParty().get().callSid());
+ assertEquals(CallType.INBOUND, java.connectedParty().get().callType());
+ }
+
+ @Test
+ void agentWithoutConnectedParty() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.Agent.newBuilder()
+ .setUserId("U-1")
+ .setOrgId("O-1")
+ .build();
+ var java = ProtoConverter.toAgent(proto);
+ assertTrue(java.connectedParty().isEmpty());
+ }
+
+ // ---- Skill ----
+
+ @Test
+ void skillWithProficiency() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.Skill.newBuilder()
+ .setSkillId("SK-1")
+ .setName("Spanish")
+ .setDescription("Spanish language")
+ .setProficiency(8)
+ .build();
+ var java = ProtoConverter.toSkill(proto);
+ assertEquals("SK-1", java.skillId());
+ assertEquals("Spanish", java.name());
+ assertTrue(java.proficiency().isPresent());
+ assertEquals(8, java.proficiency().getAsLong());
+ }
+
+ // ---- Events ----
+
+ @Test
+ void agentCallEventConversion() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.AgentCall.newBuilder()
+ .setAgentCallSid(1)
+ .setCallSid(2)
+ .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_OUTBOUND)
+ .setOrgId("O-1")
+ .setUserId("U-1")
+ .setPartnerAgentId("PA-1")
+ .setTalkDuration(com.google.protobuf.Duration.newBuilder().setSeconds(120))
+ .setCreateTime(com.google.protobuf.Timestamp.newBuilder().setSeconds(1700000000))
+ .addTaskData(
+ build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder()
+ .setKey("pool_id")
+ .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1")))
+ .build();
+
+ var java = ProtoConverter.toAgentCallEvent(proto);
+ assertEquals(1, java.agentCallSid());
+ assertEquals(2, java.callSid());
+ assertEquals(CallType.OUTBOUND, java.callType());
+ assertEquals("O-1", java.orgId());
+ assertEquals("PA-1", java.partnerAgentId());
+ assertEquals(Duration.ofSeconds(120), java.talkDuration());
+ assertNotNull(java.createTime());
+ assertEquals(1, java.taskData().size());
+ assertEquals("pool_id", java.taskData().get(0).key());
+ }
+
+ @Test
+ void telephonyResultEventConversion() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult.newBuilder()
+ .setCallSid(42)
+ .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND)
+ .setOrgId("O-1")
+ .setCallerId("+15551234567")
+ .setPhoneNumber("+15559876543")
+ .setPoolId("P-1")
+ .setRecordId("R-1")
+ .setStatus(
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED)
+ .setOutcome(
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.newBuilder()
+ .setCategory(
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Category
+ .CATEGORY_ANSWERED)
+ .setDetail(
+ build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Detail.DETAIL_GENERIC))
+ .build();
+
+ var java = ProtoConverter.toTelephonyResultEvent(proto);
+ assertEquals(42, java.callSid());
+ assertEquals(CallType.INBOUND, java.callType());
+ assertEquals("+15551234567", java.callerId());
+ assertEquals("TELEPHONY_STATUS_COMPLETED", java.status());
+ assertEquals("CATEGORY_ANSWERED", java.outcomeCategory());
+ assertEquals("DETAIL_GENERIC", java.outcomeDetail());
+ }
+
+ @Test
+ void callRecordingEventConversion() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.CallRecording.newBuilder()
+ .setRecordingId("REC-1")
+ .setOrgId("O-1")
+ .setCallSid(42)
+ .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND)
+ .setDuration(com.google.protobuf.Duration.newBuilder().setSeconds(300))
+ .setRecordingType(
+ build.buf.gen.tcnapi.exile.gate.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN)
+ .build();
+
+ var java = ProtoConverter.toCallRecordingEvent(proto);
+ assertEquals("REC-1", java.recordingId());
+ assertEquals(42, java.callSid());
+ assertEquals(Duration.ofSeconds(300), java.duration());
+ assertEquals("RECORDING_TYPE_TCN", java.recordingType());
+ }
+
+ @Test
+ void taskEventConversion() {
+ var proto =
+ build.buf.gen.tcnapi.exile.gate.v3.ExileTask.newBuilder()
+ .setTaskSid(1)
+ .setTaskGroupSid(2)
+ .setOrgId("O-1")
+ .setPoolId("P-1")
+ .setRecordId("R-1")
+ .setAttempts(3)
+ .setStatus(build.buf.gen.tcnapi.exile.gate.v3.ExileTask.TaskStatus.TASK_STATUS_RUNNING)
+ .build();
+
+ var java = ProtoConverter.toTaskEvent(proto);
+ assertEquals(1, java.taskSid());
+ assertEquals(2, java.taskGroupSid());
+ assertEquals("P-1", java.poolId());
+ assertEquals(3, java.attempts());
+ assertEquals("TASK_STATUS_RUNNING", java.status());
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java
new file mode 100644
index 0000000..424677a
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java
@@ -0,0 +1,450 @@
+package com.tcn.exile.internal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import build.buf.gen.tcnapi.exile.gate.v3.*;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.Timestamp;
+import io.grpc.Server;
+import io.grpc.inprocess.InProcessChannelBuilder;
+import io.grpc.inprocess.InProcessServerBuilder;
+import io.grpc.stub.StreamObserver;
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Benchmarks using the BenchmarkService proto for streaming throughput and ping latency. Uses an
+ * in-process server that implements BenchmarkService to isolate gRPC overhead.
+ */
+class StreamBenchmark {
+
+ private static final String SERVER_NAME = "stream-benchmark";
+ private Server server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server =
+ InProcessServerBuilder.forName(SERVER_NAME)
+ .directExecutor()
+ .addService(new InProcessBenchmarkService())
+ .build()
+ .start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (server != null) {
+ server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+ }
+
+ @Test
+ void benchmarkUnlimitedThroughput() throws Exception {
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+
+ int maxMessages = 100_000;
+ var received = new AtomicLong(0);
+ var statsRef = new AtomicReference();
+ var done = new CountDownLatch(1);
+
+ var requestObserver =
+ stub.streamBenchmark(
+ new StreamObserver<>() {
+ @Override
+ public void onNext(BenchmarkResponse response) {
+ if (response.hasMessage()) {
+ received.incrementAndGet();
+ } else if (response.hasStats()) {
+ statsRef.set(response.getStats());
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ t.printStackTrace();
+ done.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ done.countDown();
+ }
+ });
+
+ // Start with no flow control, minimal payload, limited messages.
+ requestObserver.onNext(
+ BenchmarkRequest.newBuilder()
+ .setStart(
+ StartBenchmark.newBuilder()
+ .setPayloadSize(0)
+ .setMaxMessages(maxMessages)
+ .setBatchSize(0))
+ .build());
+
+ assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out waiting for benchmark to complete");
+
+ var stats = statsRef.get();
+ assertNotNull(stats, "Should have received stats");
+
+ System.out.println(
+ "=== UNLIMITED THROUGHPUT (" + maxMessages + " messages, 0 byte payload) ===");
+ System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages());
+ System.out.printf(" client msgs received: %,d%n", received.get());
+ System.out.printf(" duration: %,d ms%n", stats.getDurationMs());
+ System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond());
+ System.out.printf(" MB/sec (server): %.2f%n", stats.getMegabytesPerSecond());
+
+ requestObserver.onCompleted();
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void benchmarkWithPayload() throws Exception {
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+
+ int maxMessages = 50_000;
+ int payloadSize = 1024; // 1KB per message
+ var received = new AtomicLong(0);
+ var statsRef = new AtomicReference();
+ var done = new CountDownLatch(1);
+
+ var requestObserver =
+ stub.streamBenchmark(
+ new StreamObserver<>() {
+ @Override
+ public void onNext(BenchmarkResponse response) {
+ if (response.hasMessage()) {
+ received.incrementAndGet();
+ } else if (response.hasStats()) {
+ statsRef.set(response.getStats());
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ t.printStackTrace();
+ done.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ done.countDown();
+ }
+ });
+
+ requestObserver.onNext(
+ BenchmarkRequest.newBuilder()
+ .setStart(
+ StartBenchmark.newBuilder()
+ .setPayloadSize(payloadSize)
+ .setMaxMessages(maxMessages)
+ .setBatchSize(0))
+ .build());
+
+ assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out");
+
+ var stats = statsRef.get();
+ assertNotNull(stats);
+
+ System.out.println(
+ "=== THROUGHPUT WITH PAYLOAD ("
+ + maxMessages
+ + " messages, "
+ + payloadSize
+ + " byte payload) ===");
+ System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages());
+ System.out.printf(" client msgs received: %,d%n", received.get());
+ System.out.printf(" duration: %,d ms%n", stats.getDurationMs());
+ System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond());
+ System.out.printf(" MB/sec (server): %.2f%n", stats.getMegabytesPerSecond());
+ System.out.printf(" total bytes: %,d%n", stats.getTotalBytes());
+
+ requestObserver.onCompleted();
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void benchmarkFlowControlled() throws Exception {
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = BenchmarkServiceGrpc.newStub(channel);
+
+ int maxMessages = 100_000;
+ int batchSize = 100;
+ var received = new AtomicLong(0);
+ var statsRef = new AtomicReference();
+ var done = new CountDownLatch(1);
+ var senderRef = new AtomicReference>();
+
+ var requestObserver =
+ stub.streamBenchmark(
+ new StreamObserver<>() {
+ @Override
+ public void onNext(BenchmarkResponse response) {
+ if (response.hasMessage()) {
+ long count = received.incrementAndGet();
+ // Send ack every batchSize messages — drives the next batch immediately.
+ if (count % batchSize == 0) {
+ senderRef
+ .get()
+ .onNext(
+ BenchmarkRequest.newBuilder()
+ .setAck(BenchmarkAck.newBuilder().setCount(batchSize))
+ .build());
+ }
+ } else if (response.hasStats()) {
+ statsRef.set(response.getStats());
+ done.countDown();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ t.printStackTrace();
+ done.countDown();
+ }
+
+ @Override
+ public void onCompleted() {
+ done.countDown();
+ }
+ });
+
+ senderRef.set(requestObserver);
+
+ requestObserver.onNext(
+ BenchmarkRequest.newBuilder()
+ .setStart(
+ StartBenchmark.newBuilder()
+ .setPayloadSize(0)
+ .setMaxMessages(maxMessages)
+ .setBatchSize(batchSize))
+ .build());
+
+ assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out waiting for flow-controlled benchmark");
+
+ var stats = statsRef.get();
+ assertNotNull(stats);
+
+ System.out.println(
+ "=== FLOW-CONTROLLED (" + maxMessages + " messages, batch_size=" + batchSize + ") ===");
+ System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages());
+ System.out.printf(" client msgs received: %,d%n", received.get());
+ System.out.printf(" duration: %,d ms%n", stats.getDurationMs());
+ System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond());
+
+ requestObserver.onCompleted();
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+ }
+
+ @Test
+ void benchmarkPingLatency() throws Exception {
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = BenchmarkServiceGrpc.newBlockingStub(channel);
+
+ int iterations = 10_000;
+ var times = new ArrayList(iterations);
+
+ // Warmup.
+ for (int i = 0; i < 100; i++) {
+ stub.ping(PingRequest.newBuilder().build());
+ }
+
+ for (int i = 0; i < iterations; i++) {
+ long now = System.nanoTime();
+ var ts =
+ Timestamp.newBuilder()
+ .setSeconds(now / 1_000_000_000L)
+ .setNanos((int) (now % 1_000_000_000L));
+ long start = System.nanoTime();
+ var resp = stub.ping(PingRequest.newBuilder().setClientTime(ts).build());
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ times.sort(Long::compareTo);
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+ long min = times.getFirst();
+ long max = times.getLast();
+ long p50 = times.get(iterations / 2);
+ long p99 = times.get((int) (iterations * 0.99));
+
+ System.out.println("=== PING LATENCY (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.3f us)%n", avg, avg / 1_000.0);
+ System.out.printf(" min: %,d ns (%.3f us)%n", min, min / 1_000.0);
+ System.out.printf(" max: %,d ns (%.3f us)%n", max, max / 1_000.0);
+ System.out.printf(" p50: %,d ns (%.3f us)%n", p50, p50 / 1_000.0);
+ System.out.printf(" p99: %,d ns (%.3f us)%n", p99, p99 / 1_000.0);
+ }
+
+ @Test
+ void benchmarkPingWithPayload() throws Exception {
+ var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build();
+ var stub = BenchmarkServiceGrpc.newBlockingStub(channel);
+
+ int iterations = 5_000;
+ byte[] payload = new byte[1024]; // 1KB payload
+ var bsPayload = ByteString.copyFrom(payload);
+ var times = new ArrayList(iterations);
+
+ // Warmup.
+ for (int i = 0; i < 100; i++) {
+ stub.ping(PingRequest.newBuilder().setPayload(bsPayload).build());
+ }
+
+ for (int i = 0; i < iterations; i++) {
+ long start = System.nanoTime();
+ stub.ping(PingRequest.newBuilder().setPayload(bsPayload).build());
+ long elapsed = System.nanoTime() - start;
+ times.add(elapsed);
+ }
+
+ channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS);
+
+ times.sort(Long::compareTo);
+ long avg = times.stream().mapToLong(Long::longValue).sum() / iterations;
+ long min = times.getFirst();
+ long max = times.getLast();
+ long p50 = times.get(iterations / 2);
+ long p99 = times.get((int) (iterations * 0.99));
+
+ System.out.println("=== PING WITH 1KB PAYLOAD (" + iterations + " iterations) ===");
+ System.out.printf(" avg: %,d ns (%.3f us)%n", avg, avg / 1_000.0);
+ System.out.printf(" min: %,d ns (%.3f us)%n", min, min / 1_000.0);
+ System.out.printf(" max: %,d ns (%.3f us)%n", max, max / 1_000.0);
+ System.out.printf(" p50: %,d ns (%.3f us)%n", p50, p50 / 1_000.0);
+ System.out.printf(" p99: %,d ns (%.3f us)%n", p99, p99 / 1_000.0);
+ }
+
+ /**
+ * In-process BenchmarkService that implements the streaming and ping protocols for local
+ * benchmarking.
+ */
+ static class InProcessBenchmarkService extends BenchmarkServiceGrpc.BenchmarkServiceImplBase {
+
+ @Override
+ public StreamObserver streamBenchmark(
+ StreamObserver responseObserver) {
+ return new StreamObserver<>() {
+ int payloadSize;
+ long maxMessages;
+ int batchSize;
+ byte[] payload;
+ long totalMessages;
+ long totalBytes;
+ long startNanos;
+ boolean stopped;
+
+ @Override
+ public void onNext(BenchmarkRequest request) {
+ if (request.hasStart()) {
+ var start = request.getStart();
+ payloadSize = start.getPayloadSize();
+ maxMessages = start.getMaxMessages();
+ batchSize = start.getBatchSize();
+ payload = new byte[payloadSize];
+ startNanos = System.nanoTime();
+ stopped = false;
+
+ if (batchSize > 0) {
+ sendBatch(batchSize);
+ } else {
+ // Unlimited: send all at once.
+ long toSend = maxMessages > 0 ? maxMessages : Long.MAX_VALUE;
+ for (long i = 0; i < toSend && !stopped; i++) {
+ sendOne();
+ }
+ sendStats();
+ }
+ } else if (request.hasAck()) {
+ // Flow-controlled: send next batch.
+ if (!stopped && (maxMessages == 0 || totalMessages < maxMessages)) {
+ sendBatch(batchSize);
+ }
+ } else if (request.hasStop()) {
+ stopped = true;
+ sendStats();
+ }
+ }
+
+ private void sendBatch(int count) {
+ for (int i = 0; i < count && (maxMessages == 0 || totalMessages < maxMessages); i++) {
+ sendOne();
+ }
+ if (maxMessages > 0 && totalMessages >= maxMessages) {
+ sendStats();
+ }
+ }
+
+ private void sendOne() {
+ var now = System.nanoTime();
+ responseObserver.onNext(
+ BenchmarkResponse.newBuilder()
+ .setMessage(
+ BenchmarkMessage.newBuilder()
+ .setSequence(totalMessages)
+ .setSendTime(
+ Timestamp.newBuilder()
+ .setSeconds(now / 1_000_000_000L)
+ .setNanos((int) (now % 1_000_000_000L)))
+ .setData(ByteString.copyFrom(payload)))
+ .build());
+ totalMessages++;
+ totalBytes += payloadSize;
+ }
+
+ private void sendStats() {
+ long elapsed = System.nanoTime() - startNanos;
+ double seconds = elapsed / 1_000_000_000.0;
+ double mps = seconds > 0 ? totalMessages / seconds : 0;
+ double mbps = seconds > 0 ? totalBytes / seconds / (1024 * 1024) : 0;
+
+ responseObserver.onNext(
+ BenchmarkResponse.newBuilder()
+ .setStats(
+ BenchmarkStats.newBuilder()
+ .setTotalMessages(totalMessages)
+ .setTotalBytes(totalBytes)
+ .setDurationMs(elapsed / 1_000_000)
+ .setMessagesPerSecond(mps)
+ .setMegabytesPerSecond(mbps))
+ .build());
+ }
+
+ @Override
+ public void onError(Throwable t) {}
+
+ @Override
+ public void onCompleted() {
+ responseObserver.onCompleted();
+ }
+ };
+ }
+
+ @Override
+ public void ping(PingRequest request, StreamObserver responseObserver) {
+ long now = System.nanoTime();
+ responseObserver.onNext(
+ PingResponse.newBuilder()
+ .setClientTime(request.getClientTime())
+ .setServerTime(
+ Timestamp.newBuilder()
+ .setSeconds(now / 1_000_000_000L)
+ .setNanos((int) (now % 1_000_000_000L)))
+ .setPayload(request.getPayload())
+ .build());
+ responseObserver.onCompleted();
+ }
+ }
+}
diff --git a/core/src/test/java/com/tcn/exile/model/ModelTest.java b/core/src/test/java/com/tcn/exile/model/ModelTest.java
new file mode 100644
index 0000000..f45e4c0
--- /dev/null
+++ b/core/src/test/java/com/tcn/exile/model/ModelTest.java
@@ -0,0 +1,66 @@
+package com.tcn.exile.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class ModelTest {
+
+ @Test
+ void pageHasMore() {
+ var page = new Page<>(List.of("a", "b"), "next-token");
+ assertTrue(page.hasMore());
+ assertEquals(2, page.items().size());
+ }
+
+ @Test
+ void pageNoMore() {
+ var page = new Page<>(List.of("a"), "");
+ assertFalse(page.hasMore());
+ }
+
+ @Test
+ void pageNullToken() {
+ var page = new Page<>(List.of("a"), null);
+ assertFalse(page.hasMore());
+ }
+
+ @Test
+ void dataRecordEquality() {
+ var r1 = new DataRecord("P-1", "R-1", Map.of("k", "v"));
+ var r2 = new DataRecord("P-1", "R-1", Map.of("k", "v"));
+ assertEquals(r1, r2);
+ }
+
+ @Test
+ void poolEquality() {
+ var p1 = new Pool("P-1", "desc", Pool.PoolStatus.READY, 10);
+ var p2 = new Pool("P-1", "desc", Pool.PoolStatus.READY, 10);
+ assertEquals(p1, p2);
+ assertNotEquals(p1, new Pool("P-2", "desc", Pool.PoolStatus.READY, 10));
+ }
+
+ @Test
+ void filterOperatorValues() {
+ assertEquals(8, Filter.Operator.values().length);
+ }
+
+ @Test
+ void callTypeValues() {
+ assertEquals(6, CallType.values().length);
+ }
+
+ @Test
+ void agentStateValues() {
+ assertEquals(19, AgentState.values().length);
+ }
+
+ @Test
+ void taskDataRecord() {
+ var td = new TaskData("key", "value");
+ assertEquals("key", td.key());
+ assertEquals("value", td.value());
+ }
+}
diff --git a/demo/build.gradle b/demo/build.gradle
index 6b430f1..b30cc44 100644
--- a/demo/build.gradle
+++ b/demo/build.gradle
@@ -1,112 +1,22 @@
plugins {
+ id("java")
+ id("application")
id("com.github.johnrengelman.shadow")
- id("io.micronaut.application")
- id("io.micronaut.aot")
-}
-
-repositories {
- mavenCentral()
-}
-
-dependencies {
- // Add Micronaut Platform BOM
- implementation(platform("io.micronaut.platform:micronaut-platform:${micronautVersion}"))
-
- annotationProcessor("io.micronaut:micronaut-http-validation")
- annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
- annotationProcessor("io.micronaut.validation:micronaut-validation-processor")
-
- implementation("io.micronaut.serde:micronaut-serde-jackson")
- implementation("io.micronaut:micronaut-http-client")
- implementation("io.micronaut:micronaut-http-server-netty")
- implementation("io.micronaut.validation:micronaut-validation")
- implementation("org.bouncycastle:bcpkix-jdk18on:1.78.1")
-
- implementation("io.grpc:grpc-core:${grpcVersion}")
- implementation("io.grpc:grpc-protobuf:${grpcVersion}")
- implementation("io.grpc:grpc-services:${grpcVersion}")
-
- // Management and monitoring
- implementation("io.micronaut:micronaut-management")
- implementation("io.micronaut.micrometer:micronaut-micrometer-core")
-
- implementation('ch.qos.logback:logback-classic:1.5.17')
- implementation('ch.qos.logback:logback-core:1.5.17')
- implementation('org.slf4j:slf4j-api:2.0.17')
-
- // Import multitenant module
- implementation(project(":core"))
- implementation(project(":logback-ext"))
-
- // Reactor for reactive streams
- implementation("io.projectreactor:reactor-core:3.5.12")
-
- implementation("jakarta.annotation:jakarta.annotation-api")
- implementation("jakarta.validation:jakarta.validation-api")
-
- implementation("ch.qos.logback:logback-classic")
-
- // Add jansi for colored console logging
- implementation("org.fusesource.jansi:jansi:2.4.1")
-
- // Add directory watcher dependency
- implementation("io.methvin:directory-watcher")
-
- // Add snakeyaml for YAML configuration parsing
- runtimeOnly("org.yaml:snakeyaml")
-
- // Add Micronaut OpenAPI and Swagger UI support
- implementation("io.micronaut.openapi:micronaut-openapi")
-
- testImplementation("io.micronaut:micronaut-http-client")
- testImplementation("org.mockito:mockito-core:5.15.2")
-
- developmentOnly("io.micronaut.controlpanel:micronaut-control-panel-management")
- developmentOnly("io.micronaut.controlpanel:micronaut-control-panel-ui")
}
application {
- mainClass.set("com.tcn.exile.demo.single.Application")
+ mainClass = 'com.tcn.exile.demo.Main'
}
-java {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
+shadowJar {
+ mergeServiceFiles()
}
-tasks.withType(JavaCompile) {
- options.encoding = "UTF-8"
- options.compilerArgs += [
- "-Amicronaut.openapi.views.spec=swagger-ui.enabled=true"
- ]
- options.compilerArgs += ["-Xlint:unchecked", "-Xlint:deprecation"]
-}
-
-tasks.withType(Test) {
- useJUnitPlatform()
-}
-
-micronaut {
- runtime("netty")
- testRuntime("junit5")
- processing {
- incremental(true)
- annotations("com.tcn.*")
- }
- aot {
- optimizeServiceLoading = false
- convertYamlToJava = false
- precomputeOperations = true
- cacheEnvironment = true
- optimizeClassLoading = true
- deduceEnvironment = true
- optimizeNetty = true
- replaceLogbackXml = true
- }
-}
+dependencies {
+ implementation(project(':core'))
+ implementation(project(':config'))
+ implementation(project(':logback-ext'))
-jar {
- manifest {
- attributes 'Implementation-Version': project.version
- }
+ // Logging runtime
+ runtimeOnly('ch.qos.logback:logback-classic:1.5.17')
}
diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java
new file mode 100644
index 0000000..6b66460
--- /dev/null
+++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java
@@ -0,0 +1,174 @@
+package com.tcn.exile.demo;
+
+import com.tcn.exile.handler.PluginBase;
+import com.tcn.exile.model.*;
+import com.tcn.exile.model.event.*;
+import com.tcn.exile.service.ConfigService;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Demo plugin that validates config, returns stub job data, and logs events. Extends {@link
+ * PluginBase} which provides default implementations for logs, diagnostics, info, shutdown, and log
+ * level control.
+ */
+public class DemoPlugin extends PluginBase {
+
+ private static final Logger log = LoggerFactory.getLogger(DemoPlugin.class);
+ private volatile boolean configured = false;
+
+ // --- Config ---
+
+ @Override
+ public boolean onConfig(ConfigService.ClientConfiguration config) {
+ log.info(
+ "Plugin received config (org={}, configName={}, payloadKeys={})",
+ config.orgId(),
+ config.configName(),
+ config.configPayload() != null ? config.configPayload().keySet() : "null");
+ // In a real plugin, you would parse config.configPayload() for DB credentials,
+ // initialize the connection pool, and return false if it fails.
+ if (config.configPayload() == null || config.configPayload().isEmpty()) {
+ configured = false;
+ return false;
+ }
+ configured = true;
+ return true;
+ }
+
+ @Override
+ public String pluginName() {
+ return "demo";
+ }
+
+ // --- Jobs ---
+
+ @Override
+ public List listPools() {
+ log.info("listPools called");
+ return List.of(
+ new Pool("pool-1", "Demo Campaign A", Pool.PoolStatus.READY, 150),
+ new Pool("pool-2", "Demo Campaign B", Pool.PoolStatus.NOT_READY, 0));
+ }
+
+ @Override
+ public Pool getPoolStatus(String poolId) {
+ log.info("getPoolStatus called for {}", poolId);
+ return new Pool(poolId, "Demo Pool", Pool.PoolStatus.READY, 42);
+ }
+
+ @Override
+ public Page getPoolRecords(String poolId, String pageToken, int pageSize) {
+ log.info("getPoolRecords called for pool={} page={} size={}", poolId, pageToken, pageSize);
+ return new Page<>(
+ List.of(
+ new DataRecord(poolId, "rec-1", Map.of("name", "John Doe", "phone", "+15551234567")),
+ new DataRecord(poolId, "rec-2", Map.of("name", "Jane Smith", "phone", "+15559876543"))),
+ "");
+ }
+
+ @Override
+ public Page searchRecords(List filters, String pageToken, int pageSize) {
+ log.info("searchRecords called with {} filters", filters.size());
+ return new Page<>(
+ List.of(
+ new DataRecord("pool-1", "rec-1", Map.of("name", "Search Result", "matched", true))),
+ "");
+ }
+
+ @Override
+ public List getRecordFields(String poolId, String recordId, List fieldNames) {
+ log.info("getRecordFields called for {}/{} fields={}", poolId, recordId, fieldNames);
+ return List.of(
+ new Field("first_name", "John", poolId, recordId),
+ new Field("last_name", "Doe", poolId, recordId),
+ new Field("balance", "1250.00", poolId, recordId));
+ }
+
+ @Override
+ public boolean setRecordFields(String poolId, String recordId, List fields) {
+ log.info("setRecordFields called for {}/{} with {} fields", poolId, recordId, fields.size());
+ return true;
+ }
+
+ @Override
+ public String createPayment(String poolId, String recordId, Map paymentData) {
+ log.info("createPayment called for {}/{}: {}", poolId, recordId, paymentData);
+ return "PAY-" + System.currentTimeMillis();
+ }
+
+ @Override
+ public DataRecord popAccount(String poolId, String recordId) {
+ log.info("popAccount called for {}/{}", poolId, recordId);
+ return new DataRecord(poolId, recordId, Map.of("name", "Popped Account", "status", "active"));
+ }
+
+ @Override
+ public Map executeLogic(String logicName, Map parameters) {
+ log.info("executeLogic called: {} params={}", logicName, parameters);
+ return Map.of("result", "ok", "logic", logicName);
+ }
+
+ // --- Events ---
+
+ @Override
+ public void onAgentCall(AgentCallEvent event) {
+ log.info(
+ "AgentCall: callSid={} type={} agent={} talk={}s",
+ event.callSid(),
+ event.callType(),
+ event.partnerAgentId(),
+ event.talkDuration().toSeconds());
+ }
+
+ @Override
+ public void onTelephonyResult(TelephonyResultEvent event) {
+ log.info(
+ "TelephonyResult: callSid={} type={} status={} outcome={} phone={}",
+ event.callSid(),
+ event.callType(),
+ event.status(),
+ event.outcomeCategory(),
+ event.phoneNumber());
+ }
+
+ @Override
+ public void onAgentResponse(AgentResponseEvent event) {
+ log.info(
+ "AgentResponse: callSid={} agent={} key={} value={}",
+ event.callSid(),
+ event.partnerAgentId(),
+ event.responseKey(),
+ event.responseValue());
+ }
+
+ @Override
+ public void onTransferInstance(TransferInstanceEvent event) {
+ log.info(
+ "TransferInstance: id={} type={} result={}",
+ event.transferInstanceId(),
+ event.transferType(),
+ event.transferResult());
+ }
+
+ @Override
+ public void onCallRecording(CallRecordingEvent event) {
+ log.info(
+ "CallRecording: id={} callSid={} duration={}s",
+ event.recordingId(),
+ event.callSid(),
+ event.duration().toSeconds());
+ }
+
+ @Override
+ public void onTask(TaskEvent event) {
+ log.info(
+ "Task: sid={} pool={} record={} status={}",
+ event.taskSid(),
+ event.poolId(),
+ event.recordId(),
+ event.status());
+ }
+}
diff --git a/demo/src/main/java/com/tcn/exile/demo/Main.java b/demo/src/main/java/com/tcn/exile/demo/Main.java
new file mode 100644
index 0000000..34f4fca
--- /dev/null
+++ b/demo/src/main/java/com/tcn/exile/demo/Main.java
@@ -0,0 +1,90 @@
+package com.tcn.exile.demo;
+
+import com.tcn.exile.config.ExileClientManager;
+import java.nio.file.Path;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Demo application showing how to use the sati client library.
+ *
+ *