From f4cd32f6a7ec4d3b96b16222d1d7ee0959a103e2 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:22:18 -0600 Subject: [PATCH 01/50] Redesign sati as plain Java client for v3 protocol Remove all Micronaut dependencies. The library is now plain Java 21 with only gRPC, protobuf, and SLF4J. Architecture: - ExileClient: builder-based entry point, composes WorkStream + services - ExileConfig: immutable connection config (certs, endpoint) - WorkStreamClient: single bidirectional gRPC stream implementing the v3 WorkStream protocol (replaces GateClientJobQueue, GateClientEventStream, and SubmitJobResults) - JobHandler interface: integrations implement to process jobs, return results directly (no more calling gateClient.submitJobResults internally) - EventHandler interface: integrations implement to handle events - Domain service clients: thin wrappers around AgentService, CallService, RecordingService, ScrubListService, ConfigService gRPC stubs Removed: - Micronaut framework (context, core, serde, http, validation, aot) - GateClientAbstract, GateClientJobQueue, GateClientEventStream, GateClientPollEvents, GateClientJobStream (all replaced by WorkStreamClient) - PluginInterface god interface (replaced by JobHandler + EventHandler) - 20+ model wrapper classes (use proto types directly) - Config/DiagnosticsService (replaced by ExileConfig/ConfigService) - StructuredLogger/LogCategory (use SLF4J directly) - Jackson, Reactor, HikariCP, Jakarta, SnakeYAML dependencies - demo module (was Micronaut app with copy-pasted controllers) What integrations need to change: - Implement JobHandler (return results) instead of PluginInterface (void) - Implement EventHandler instead of PluginInterface event methods - Use ExileClient.builder() instead of manually creating GateClient + Plugin + GateClientJobQueue + GateClientEventStream - Use proto types directly instead of model wrappers - Use domain service clients (client.agents(), client.calls()) instead of GateClient for unary RPCs Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 42 +- core/build.gradle | 95 +- .../main/java/com/tcn/exile/ExileClient.java | 195 ++ .../main/java/com/tcn/exile/ExileConfig.java | 130 ++ .../java/com/tcn/exile/config/Config.java | 483 ----- .../tcn/exile/config/DiagnosticsService.java | 1652 ---------------- .../gateclients/UnconfiguredException.java | 35 - .../exile/gateclients/v2/BuildVersion.java | 23 - .../tcn/exile/gateclients/v2/GateClient.java | 328 ---- .../gateclients/v2/GateClientAbstract.java | 578 ------ .../v2/GateClientConfiguration.java | 150 -- .../gateclients/v2/GateClientEventStream.java | 413 ---- .../gateclients/v2/GateClientJobQueue.java | 364 ---- .../gateclients/v2/GateClientJobStream.java | 351 ---- .../gateclients/v2/GateClientPollEvents.java | 181 -- .../com/tcn/exile/handler/EventHandler.java | 28 + .../com/tcn/exile/handler/JobHandler.java | 79 + .../java/com/tcn/exile/internal/Backoff.java | 41 + .../tcn/exile/internal/ChannelFactory.java | 56 + .../tcn/exile/internal/WorkStreamClient.java | 320 +++ .../java/com/tcn/exile/log/LogCategory.java | 48 - .../java/com/tcn/exile/log/LoggerLevels.java | 36 - .../com/tcn/exile/log/StructuredLogger.java | 192 -- .../main/java/com/tcn/exile/models/Agent.java | 35 - .../java/com/tcn/exile/models/AgentState.java | 78 - .../com/tcn/exile/models/AgentStatus.java | 30 - .../tcn/exile/models/AgentUpsertRequest.java | 29 - .../java/com/tcn/exile/models/CallType.java | 38 - .../com/tcn/exile/models/ConnectedParty.java | 26 - .../tcn/exile/models/DiagnosticsResult.java | 1710 ----------------- .../com/tcn/exile/models/DialRequest.java | 31 - .../com/tcn/exile/models/DialResponse.java | 29 - .../com/tcn/exile/models/GateClientState.java | 25 - .../tcn/exile/models/GateClientStatus.java | 22 - .../java/com/tcn/exile/models/LookupType.java | 34 - .../tcn/exile/models/ManualDialResult.java | 77 - .../java/com/tcn/exile/models/OrgInfo.java | 30 - .../tcn/exile/models/PluginConfigEvent.java | 120 -- .../main/java/com/tcn/exile/models/Pool.java | 24 - .../java/com/tcn/exile/models/PoolStatus.java | 34 - .../java/com/tcn/exile/models/Record.java | 26 - .../tcn/exile/models/RecordingResponse.java | 23 - .../java/com/tcn/exile/models/ScrubList.java | 22 - .../com/tcn/exile/models/ScrubListEntry.java | 32 - .../com/tcn/exile/models/ScrubListType.java | 39 - .../com/tcn/exile/models/SetAgentState.java | 70 - .../exile/models/SetAgentStatusResponse.java | 22 - .../tcn/exile/models/TenantLogsResult.java | 174 -- .../main/java/com/tcn/exile/plugin/Job.java | 19 - .../com/tcn/exile/plugin/PluginInterface.java | 137 -- .../com/tcn/exile/plugin/PluginStatus.java | 30 - .../com/tcn/exile/service/AgentService.java | 71 + .../com/tcn/exile/service/CallService.java | 43 + .../com/tcn/exile/service/ConfigService.java | 36 + .../tcn/exile/service/RecordingService.java | 31 + .../tcn/exile/service/ScrubListService.java | 30 + .../src/main/resources/application.properties | 2 - core/src/main/resources/application.yml | 30 - core/src/main/resources/logback.xml | 21 - demo/build.gradle | 112 -- .../exile/demo/single/AdminController.java | 66 - .../exile/demo/single/AgentsController.java | 384 ---- .../tcn/exile/demo/single/Application.java | 38 - .../demo/single/ConfigChangeWatcher.java | 267 --- .../com/tcn/exile/demo/single/DemoPlugin.java | 593 ------ .../tcn/exile/demo/single/LogsController.java | 87 - .../exile/demo/single/VersionController.java | 33 - .../tcn/exile/demo/single/VersionInfo.java | 23 - .../resources/META-INF/openapi.properties | 1 - demo/src/main/resources/application.yml | 49 - demo/src/main/resources/logback.xml | 22 - gradle.properties | 3 - settings.gradle | 14 +- 73 files changed, 1081 insertions(+), 9661 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/ExileClient.java create mode 100644 core/src/main/java/com/tcn/exile/ExileConfig.java delete mode 100644 core/src/main/java/com/tcn/exile/config/Config.java delete mode 100644 core/src/main/java/com/tcn/exile/config/DiagnosticsService.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java delete mode 100644 core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java create mode 100644 core/src/main/java/com/tcn/exile/handler/EventHandler.java create mode 100644 core/src/main/java/com/tcn/exile/handler/JobHandler.java create mode 100644 core/src/main/java/com/tcn/exile/internal/Backoff.java create mode 100644 core/src/main/java/com/tcn/exile/internal/ChannelFactory.java create mode 100644 core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java delete mode 100644 core/src/main/java/com/tcn/exile/log/LogCategory.java delete mode 100644 core/src/main/java/com/tcn/exile/log/LoggerLevels.java delete mode 100644 core/src/main/java/com/tcn/exile/log/StructuredLogger.java delete mode 100644 core/src/main/java/com/tcn/exile/models/Agent.java delete mode 100644 core/src/main/java/com/tcn/exile/models/AgentState.java delete mode 100644 core/src/main/java/com/tcn/exile/models/AgentStatus.java delete mode 100644 core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java delete mode 100644 core/src/main/java/com/tcn/exile/models/CallType.java delete mode 100644 core/src/main/java/com/tcn/exile/models/ConnectedParty.java delete mode 100644 core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java delete mode 100644 core/src/main/java/com/tcn/exile/models/DialRequest.java delete mode 100644 core/src/main/java/com/tcn/exile/models/DialResponse.java delete mode 100644 core/src/main/java/com/tcn/exile/models/GateClientState.java delete mode 100644 core/src/main/java/com/tcn/exile/models/GateClientStatus.java delete mode 100644 core/src/main/java/com/tcn/exile/models/LookupType.java delete mode 100644 core/src/main/java/com/tcn/exile/models/ManualDialResult.java delete mode 100644 core/src/main/java/com/tcn/exile/models/OrgInfo.java delete mode 100644 core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java delete mode 100644 core/src/main/java/com/tcn/exile/models/Pool.java delete mode 100644 core/src/main/java/com/tcn/exile/models/PoolStatus.java delete mode 100644 core/src/main/java/com/tcn/exile/models/Record.java delete mode 100644 core/src/main/java/com/tcn/exile/models/RecordingResponse.java delete mode 100644 core/src/main/java/com/tcn/exile/models/ScrubList.java delete mode 100644 core/src/main/java/com/tcn/exile/models/ScrubListEntry.java delete mode 100644 core/src/main/java/com/tcn/exile/models/ScrubListType.java delete mode 100644 core/src/main/java/com/tcn/exile/models/SetAgentState.java delete mode 100644 core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java delete mode 100644 core/src/main/java/com/tcn/exile/models/TenantLogsResult.java delete mode 100644 core/src/main/java/com/tcn/exile/plugin/Job.java delete mode 100644 core/src/main/java/com/tcn/exile/plugin/PluginInterface.java delete mode 100644 core/src/main/java/com/tcn/exile/plugin/PluginStatus.java create mode 100644 core/src/main/java/com/tcn/exile/service/AgentService.java create mode 100644 core/src/main/java/com/tcn/exile/service/CallService.java create mode 100644 core/src/main/java/com/tcn/exile/service/ConfigService.java create mode 100644 core/src/main/java/com/tcn/exile/service/RecordingService.java create mode 100644 core/src/main/java/com/tcn/exile/service/ScrubListService.java delete mode 100644 core/src/main/resources/application.properties delete mode 100644 core/src/main/resources/application.yml delete mode 100644 core/src/main/resources/logback.xml delete mode 100644 demo/build.gradle delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/AdminController.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/Application.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/LogsController.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/VersionController.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java delete mode 100644 demo/src/main/resources/META-INF/openapi.properties delete mode 100644 demo/src/main/resources/application.yml delete mode 100644 demo/src/main/resources/logback.xml diff --git a/build.gradle b/build.gradle index 9a33213..204e623 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,6 @@ 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. - * - */''') } } } diff --git a/core/build.gradle b/core/build.gradle index 66800ae..453f1ad 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,91 +1,24 @@ plugins { 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 + // exileapi v3 generated stubs 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}") - - // Add SnakeYAML for YAML configuration support - runtimeOnly("org.yaml:snakeyaml") - testRuntimeOnly("io.micronaut:micronaut-http-client") - - 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}") + // protobuf + implementation("com.google.protobuf:protobuf-java:${protobufVersion}") } -java { - sourceCompatibility = JavaVersion.toVersion("21") - targetCompatibility = JavaVersion.toVersion("21") -} - -graalvmNative.toolchainDetection = false - -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 - } -} - -// Configure JaCoCo for test coverage jacoco { toolVersion = "0.8.11" } @@ -95,23 +28,9 @@ jacocoTestReport { xml.required = true html.required = true } - - afterEvaluate { - classDirectories.setFrom(files(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - '**/*$Lambda$*/**', - '**/*$$*/**', - '**/*_Micronaut*/**', - '**/micronaut/**' - ]) - })) - } + dependsOn test } test { 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..1abf4a1 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -0,0 +1,195 @@ +package com.tcn.exile; + +import com.tcn.exile.handler.EventHandler; +import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.internal.ChannelFactory; +import com.tcn.exile.internal.WorkStreamClient; +import com.tcn.exile.service.*; +import io.grpc.ManagedChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tcnapi.exile.worker.v3.WorkType; + +/** + * Main entry point for the Exile client library. + * + *

Connects to the gate server, opens a work stream, and exposes domain service clients for + * making unary RPCs. + * + *

Usage: + * + *

{@code
+ * var client = ExileClient.builder()
+ *     .config(exileConfig)
+ *     .clientName("sati-finvi-prod-1")
+ *     .clientVersion("3.0.0")
+ *     .maxConcurrency(5)
+ *     .jobHandler(myJobHandler)
+ *     .eventHandler(myEventHandler)
+ *     .build();
+ *
+ * client.start();
+ *
+ * // Use domain services for unary RPCs.
+ * var agents = client.agents().listAgents(ListAgentsRequest.getDefaultInstance());
+ * var status = client.calls().getRecordingStatus(req);
+ *
+ * // When done:
+ * client.close();
+ * }
+ */ +public final class ExileClient implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(ExileClient.class); + + private final ExileConfig config; + private final WorkStreamClient workStream; + + // Shared channel for unary RPCs (separate from the stream channel). + private final ManagedChannel serviceChannel; + + // Domain service clients. + private final AgentService agentService; + private final CallService callService; + private final RecordingService recordingService; + private final ScrubListService scrubListService; + private final ConfigService configService; + + private ExileClient(Builder builder) { + this.config = builder.config; + + this.workStream = + new WorkStreamClient( + config, + builder.jobHandler, + builder.eventHandler, + builder.clientName, + builder.clientVersion, + builder.maxConcurrency, + builder.capabilities); + + // Create a shared channel for unary RPCs. + this.serviceChannel = ChannelFactory.create(config); + this.agentService = new AgentService(serviceChannel); + this.callService = new CallService(serviceChannel); + this.recordingService = new RecordingService(serviceChannel); + this.scrubListService = new ScrubListService(serviceChannel); + this.configService = new ConfigService(serviceChannel); + } + + /** Start the work stream. Call this after building the client. */ + public void start() { + log.info("Starting ExileClient for org={}", config.org()); + workStream.start(); + } + + public ExileConfig config() { + return config; + } + + 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; + } + + @Override + public void close() { + log.info("Shutting down ExileClient"); + workStream.close(); + ChannelFactory.shutdown(serviceChannel); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private ExileConfig config; + private JobHandler jobHandler = new JobHandler() {}; + private EventHandler eventHandler = new EventHandler() {}; + private String clientName = "sati"; + private String clientVersion = "unknown"; + private int maxConcurrency = 5; + private List capabilities = new ArrayList<>(); + + private Builder() {} + + /** Required. Connection configuration (certs, endpoint). */ + public Builder config(ExileConfig config) { + this.config = Objects.requireNonNull(config); + return this; + } + + /** + * Job handler implementation. Defaults to a no-op handler that rejects all jobs with + * UnsupportedOperationException. + */ + public Builder jobHandler(JobHandler jobHandler) { + this.jobHandler = Objects.requireNonNull(jobHandler); + return this; + } + + /** + * Event handler implementation. Defaults to a no-op handler that acknowledges all events + * without processing. + */ + public Builder eventHandler(EventHandler eventHandler) { + this.eventHandler = Objects.requireNonNull(eventHandler); + return this; + } + + /** Human-readable client name for diagnostics. */ + public Builder clientName(String clientName) { + this.clientName = Objects.requireNonNull(clientName); + return this; + } + + /** Client software version for diagnostics. */ + public Builder clientVersion(String clientVersion) { + this.clientVersion = Objects.requireNonNull(clientVersion); + return this; + } + + /** + * Maximum number of work items to process concurrently. Controls the Pull(max_items) sent to + * the server. Default: 5. + */ + public Builder maxConcurrency(int maxConcurrency) { + if (maxConcurrency < 1) throw new IllegalArgumentException("maxConcurrency must be >= 1"); + this.maxConcurrency = maxConcurrency; + return this; + } + + /** + * Work types this client can handle. Empty (default) means all types. Use this to limit what + * the server dispatches to this client. + */ + public Builder capabilities(List capabilities) { + this.capabilities = new ArrayList<>(Objects.requireNonNull(capabilities)); + return this; + } + + public ExileClient build() { + Objects.requireNonNull(config, "config 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..d238d7b --- /dev/null +++ b/core/src/main/java/com/tcn/exile/ExileConfig.java @@ -0,0 +1,130 @@ +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; + + // 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; + } + + 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; + } + + /** 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 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 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/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..ca217a7 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/EventHandler.java @@ -0,0 +1,28 @@ +package com.tcn.exile.handler; + +import tcnapi.exile.types.v3.*; + +/** + * 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(AgentCall call) throws Exception {} + + default void onTelephonyResult(TelephonyResult result) throws Exception {} + + default void onAgentResponse(AgentResponse response) throws Exception {} + + default void onTransferInstance(TransferInstance transfer) throws Exception {} + + default void onCallRecording(CallRecording recording) throws Exception {} + + default void onTask(Task task) 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..3d94bf1 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/JobHandler.java @@ -0,0 +1,79 @@ +package com.tcn.exile.handler; + +import tcnapi.exile.worker.v3.*; + +/** + * Handles jobs dispatched by the gate server. Each method receives a task payload and returns the + * corresponding result. The {@link com.tcn.exile.internal.WorkStreamClient} sends the result back + * to the server automatically. + * + *

Implementations should throw an exception if the job cannot be processed. The stream client + * will submit an {@code ErrorResult} with the exception message and nack the work item. + * + *

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 ListPoolsResult listPools(ListPoolsTask task) throws Exception { + throw new UnsupportedOperationException("listPools not implemented"); + } + + default GetPoolStatusResult getPoolStatus(GetPoolStatusTask task) throws Exception { + throw new UnsupportedOperationException("getPoolStatus not implemented"); + } + + default GetPoolRecordsResult getPoolRecords(GetPoolRecordsTask task) throws Exception { + throw new UnsupportedOperationException("getPoolRecords not implemented"); + } + + default SearchRecordsResult searchRecords(SearchRecordsTask task) throws Exception { + throw new UnsupportedOperationException("searchRecords not implemented"); + } + + default GetRecordFieldsResult getRecordFields(GetRecordFieldsTask task) throws Exception { + throw new UnsupportedOperationException("getRecordFields not implemented"); + } + + default SetRecordFieldsResult setRecordFields(SetRecordFieldsTask task) throws Exception { + throw new UnsupportedOperationException("setRecordFields not implemented"); + } + + default CreatePaymentResult createPayment(CreatePaymentTask task) throws Exception { + throw new UnsupportedOperationException("createPayment not implemented"); + } + + default PopAccountResult popAccount(PopAccountTask task) throws Exception { + throw new UnsupportedOperationException("popAccount not implemented"); + } + + default ExecuteLogicResult executeLogic(ExecuteLogicTask task) throws Exception { + throw new UnsupportedOperationException("executeLogic not implemented"); + } + + default InfoResult info(InfoTask task) throws Exception { + throw new UnsupportedOperationException("info not implemented"); + } + + default ShutdownResult shutdown(ShutdownTask task) throws Exception { + throw new UnsupportedOperationException("shutdown not implemented"); + } + + default LoggingResult logging(LoggingTask task) throws Exception { + throw new UnsupportedOperationException("logging not implemented"); + } + + default DiagnosticsResult diagnostics(DiagnosticsTask task) throws Exception { + throw new UnsupportedOperationException("diagnostics not implemented"); + } + + default ListTenantLogsResult listTenantLogs(ListTenantLogsTask task) throws Exception { + throw new UnsupportedOperationException("listTenantLogs not implemented"); + } + + default SetLogLevelResult setLogLevel(SetLogLevelTask task) throws Exception { + throw new UnsupportedOperationException("setLogLevel not implemented"); + } +} 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..f174cd4 --- /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 = 2_000; + private static final long MAX_MS = 30_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..638a3a6 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java @@ -0,0 +1,56 @@ +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 io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +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 { + SslContext sslContext = + GrpcSslContexts.forClient() + .trustManager( + new ByteArrayInputStream(config.rootCert().getBytes(StandardCharsets.UTF_8))) + .keyManager( + new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)), + new ByteArrayInputStream(config.privateKey().getBytes(StandardCharsets.UTF_8))) + .build(); + + return NettyChannelBuilder.forAddress(config.apiHostname(), config.apiPort()) + .overrideAuthority("exile-proxy") + .sslContext(sslContext) + .keepAliveTime(32, TimeUnit.SECONDS) + .keepAliveTimeout(30, TimeUnit.SECONDS) + .keepAliveWithoutCalls(true) + .idleTimeout(30, TimeUnit.MINUTES) + .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(); + } + } +} 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..a8e1105 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -0,0 +1,320 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.ExileConfig; +import com.tcn.exile.handler.EventHandler; +import com.tcn.exile.handler.JobHandler; +import io.grpc.ManagedChannel; +import io.grpc.stub.StreamObserver; +import java.time.Duration; +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.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tcnapi.exile.worker.v3.*; + +/** + * Implements the v3 WorkStream protocol over a single bidirectional gRPC stream. + * + *

Lifecycle: {@link #start()} opens the stream in a background thread that reconnects + * automatically. {@link #close()} shuts everything down. + * + *

The client uses credit-based flow control: it sends {@code Pull(max_items=N)} to control how + * many concurrent work items it processes. Work items are dispatched to virtual threads, so the + * main stream thread never blocks on handler execution. + */ +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(); + + private volatile ManagedChannel channel; + private volatile Thread streamThread; + + 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; + } + + /** Start the work stream in a background thread. Returns immediately. */ + public void start() { + if (!running.compareAndSet(false, true)) { + throw new IllegalStateException("Already started"); + } + streamThread = + Thread.ofPlatform() + .name("exile-work-stream") + .daemon(true) + .start(this::reconnectLoop); + } + + private void reconnectLoop() { + var backoff = new Backoff(); + while (running.get()) { + try { + backoff.sleep(); + runStream(); + // Stream ended normally (server closed). Reset and reconnect. + backoff.reset(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + backoff.recordFailure(); + log.warn("Stream disconnected (attempt {}): {}", backoff, e.getMessage()); + } + } + log.info("Work stream loop exited"); + } + + private void runStream() throws InterruptedException { + channel = ChannelFactory.create(config); + 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) { + log.warn("Stream error: {}", t.getMessage()); + latch.countDown(); + } + + @Override + public void onCompleted() { + log.info("Stream completed by server"); + latch.countDown(); + } + }); + + requestObserver.set(observer); + + // 1. Register + send( + WorkRequest.newBuilder() + .setRegister( + Register.newBuilder() + .setClientName(clientName) + .setClientVersion(clientVersion) + .addAllCapabilities(capabilities)) + .build()); + + // 2. Initial pull + pull(maxConcurrency); + + // 3. Wait until stream ends + latch.await(); + } finally { + requestObserver.set(null); + inflight.set(0); + ChannelFactory.shutdown(channel); + channel = null; + } + } + + private void handleResponse(WorkResponse response) { + switch (response.getPayloadCase()) { + case REGISTERED -> { + var reg = response.getRegistered(); + log.info( + "Registered as {} (heartbeat={}s, lease={}s, max_inflight={})", + reg.getClientId(), + reg.getHeartbeatInterval().getSeconds(), + reg.getDefaultLease().getSeconds(), + reg.getMaxInflight()); + } + + case WORK_ITEM -> { + var item = response.getWorkItem(); + inflight.incrementAndGet(); + workerPool.submit(() -> processWorkItem(item)); + } + + case RESULT_ACCEPTED -> { + log.debug("Result accepted: {}", response.getResultAccepted().getWorkId()); + } + + case LEASE_EXPIRING -> { + var warning = response.getLeaseExpiring(); + log.debug( + "Lease expiring for {}, {}s remaining", + warning.getWorkId(), + warning.getRemaining().getSeconds()); + // Auto-extend by default. Integrations can override via ExileClient.Builder. + send( + WorkRequest.newBuilder() + .setExtendLease( + ExtendLease.newBuilder() + .setWorkId(warning.getWorkId()) + .setExtension( + com.google.protobuf.Duration.newBuilder().setSeconds(300).build())) + .build()); + } + + case LEASE_EXTENDED -> { + log.debug("Lease extended for {}", response.getLeaseExtended().getWorkId()); + } + + case NACK_ACCEPTED -> { + log.debug("Nack accepted: {}", response.getNackAccepted().getWorkId()); + } + + 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(); + log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); + } + + default -> log.debug("Unknown response type: {}", response.getPayloadCase()); + } + } + + private void processWorkItem(WorkItem item) { + String workId = item.getWorkId(); + try { + 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()); + } + } catch (Exception e) { + log.warn("Work item {} failed: {}", workId, e.getMessage()); + send( + WorkRequest.newBuilder() + .setResult( + Result.newBuilder() + .setWorkId(workId) + .setFinal(true) + .setError(ErrorResult.newBuilder().setMessage(e.getMessage()))) + .build()); + } finally { + inflight.decrementAndGet(); + pull(1); // Replenish one slot. + } + } + + private Result.Builder dispatchJob(WorkItem item) throws Exception { + var b = Result.newBuilder().setWorkId(item.getWorkId()).setFinal(true); + switch (item.getTaskCase()) { + case LIST_POOLS -> b.setListPools(jobHandler.listPools(item.getListPools())); + case GET_POOL_STATUS -> b.setGetPoolStatus(jobHandler.getPoolStatus(item.getGetPoolStatus())); + case GET_POOL_RECORDS -> + b.setGetPoolRecords(jobHandler.getPoolRecords(item.getGetPoolRecords())); + case SEARCH_RECORDS -> b.setSearchRecords(jobHandler.searchRecords(item.getSearchRecords())); + case GET_RECORD_FIELDS -> + b.setGetRecordFields(jobHandler.getRecordFields(item.getGetRecordFields())); + case SET_RECORD_FIELDS -> + b.setSetRecordFields(jobHandler.setRecordFields(item.getSetRecordFields())); + case CREATE_PAYMENT -> b.setCreatePayment(jobHandler.createPayment(item.getCreatePayment())); + case POP_ACCOUNT -> b.setPopAccount(jobHandler.popAccount(item.getPopAccount())); + case EXECUTE_LOGIC -> b.setExecuteLogic(jobHandler.executeLogic(item.getExecuteLogic())); + case INFO -> b.setInfo(jobHandler.info(item.getInfo())); + case SHUTDOWN -> b.setShutdown(jobHandler.shutdown(item.getShutdown())); + case LOGGING -> b.setLogging(jobHandler.logging(item.getLogging())); + case DIAGNOSTICS -> b.setDiagnostics(jobHandler.diagnostics(item.getDiagnostics())); + case LIST_TENANT_LOGS -> + b.setListTenantLogs(jobHandler.listTenantLogs(item.getListTenantLogs())); + case SET_LOG_LEVEL -> b.setSetLogLevel(jobHandler.setLogLevel(item.getSetLogLevel())); + default -> throw new UnsupportedOperationException("Unknown job type: " + item.getTaskCase()); + } + return b; + } + + private void dispatchEvent(WorkItem item) throws Exception { + switch (item.getTaskCase()) { + case AGENT_CALL -> eventHandler.onAgentCall(item.getAgentCall()); + case TELEPHONY_RESULT -> eventHandler.onTelephonyResult(item.getTelephonyResult()); + case AGENT_RESPONSE -> eventHandler.onAgentResponse(item.getAgentResponse()); + case TRANSFER_INSTANCE -> eventHandler.onTransferInstance(item.getTransferInstance()); + case CALL_RECORDING -> eventHandler.onCallRecording(item.getCallRecording()); + case TASK -> eventHandler.onTask(item.getTask()); + default -> + throw new UnsupportedOperationException("Unknown event type: " + item.getTaskCase()); + } + } + + 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()); + } + } + } + + @Override + public void close() { + running.set(false); + 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/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..636ab38 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -0,0 +1,71 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import tcnapi.exile.agent.v3.*; + +/** Thin wrapper around the v3 AgentService gRPC stub. */ +public final class AgentService { + + private final AgentServiceGrpc.AgentServiceBlockingStub stub; + + public AgentService(ManagedChannel channel) { + this.stub = AgentServiceGrpc.newBlockingStub(channel); + } + + public GetAgentResponse getAgent(GetAgentRequest request) { + return stub.getAgent(request); + } + + public ListAgentsResponse listAgents(ListAgentsRequest request) { + return stub.listAgents(request); + } + + public UpsertAgentResponse upsertAgent(UpsertAgentRequest request) { + return stub.upsertAgent(request); + } + + public SetAgentCredentialsResponse setAgentCredentials(SetAgentCredentialsRequest request) { + return stub.setAgentCredentials(request); + } + + public GetAgentStatusResponse getAgentStatus(GetAgentStatusRequest request) { + return stub.getAgentStatus(request); + } + + public UpdateAgentStatusResponse updateAgentStatus(UpdateAgentStatusRequest request) { + return stub.updateAgentStatus(request); + } + + public MuteAgentResponse muteAgent(MuteAgentRequest request) { + return stub.muteAgent(request); + } + + public UnmuteAgentResponse unmuteAgent(UnmuteAgentRequest request) { + return stub.unmuteAgent(request); + } + + public AddAgentCallResponseResponse addAgentCallResponse(AddAgentCallResponseRequest request) { + return stub.addAgentCallResponse(request); + } + + public ListHuntGroupPauseCodesResponse listHuntGroupPauseCodes( + ListHuntGroupPauseCodesRequest request) { + return stub.listHuntGroupPauseCodes(request); + } + + public ListSkillsResponse listSkills(ListSkillsRequest request) { + return stub.listSkills(request); + } + + public ListAgentSkillsResponse listAgentSkills(ListAgentSkillsRequest request) { + return stub.listAgentSkills(request); + } + + public AssignAgentSkillResponse assignAgentSkill(AssignAgentSkillRequest request) { + return stub.assignAgentSkill(request); + } + + public UnassignAgentSkillResponse unassignAgentSkill(UnassignAgentSkillRequest request) { + return stub.unassignAgentSkill(request); + } +} 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..64c9fe3 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -0,0 +1,43 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import tcnapi.exile.call.v3.*; + +/** Thin wrapper around the v3 CallService gRPC stub. */ +public final class CallService { + + private final CallServiceGrpc.CallServiceBlockingStub stub; + + public CallService(ManagedChannel channel) { + this.stub = CallServiceGrpc.newBlockingStub(channel); + } + + public DialResponse dial(DialRequest request) { + return stub.dial(request); + } + + public TransferResponse transfer(TransferRequest request) { + return stub.transfer(request); + } + + public SetHoldStateResponse setHoldState(SetHoldStateRequest request) { + return stub.setHoldState(request); + } + + public StartCallRecordingResponse startCallRecording(StartCallRecordingRequest request) { + return stub.startCallRecording(request); + } + + public StopCallRecordingResponse stopCallRecording(StopCallRecordingRequest request) { + return stub.stopCallRecording(request); + } + + public GetRecordingStatusResponse getRecordingStatus(GetRecordingStatusRequest request) { + return stub.getRecordingStatus(request); + } + + public ListComplianceRulesetsResponse listComplianceRulesets( + ListComplianceRulesetsRequest request) { + return stub.listComplianceRulesets(request); + } +} 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..ecef73e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -0,0 +1,36 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import tcnapi.exile.config.v3.*; + +/** Thin wrapper around the v3 ConfigService gRPC stub. */ +public final class ConfigService { + + private final ConfigServiceGrpc.ConfigServiceBlockingStub stub; + + public ConfigService(ManagedChannel channel) { + this.stub = ConfigServiceGrpc.newBlockingStub(channel); + } + + public GetClientConfigurationResponse getClientConfiguration( + GetClientConfigurationRequest request) { + return stub.getClientConfiguration(request); + } + + public GetOrganizationInfoResponse getOrganizationInfo(GetOrganizationInfoRequest request) { + return stub.getOrganizationInfo(request); + } + + public RotateCertificateResponse rotateCertificate(RotateCertificateRequest request) { + return stub.rotateCertificate(request); + } + + public LogResponse log(LogRequest request) { + return stub.log(request); + } + + public AddRecordToJourneyBufferResponse addRecordToJourneyBuffer( + AddRecordToJourneyBufferRequest request) { + return stub.addRecordToJourneyBuffer(request); + } +} 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..2592497 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -0,0 +1,31 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import tcnapi.exile.recording.v3.*; + +/** Thin wrapper around the v3 RecordingService gRPC stub. */ +public final class RecordingService { + + private final RecordingServiceGrpc.RecordingServiceBlockingStub stub; + + public RecordingService(ManagedChannel channel) { + this.stub = RecordingServiceGrpc.newBlockingStub(channel); + } + + public SearchVoiceRecordingsResponse searchVoiceRecordings( + SearchVoiceRecordingsRequest request) { + return stub.searchVoiceRecordings(request); + } + + public GetDownloadLinkResponse getDownloadLink(GetDownloadLinkRequest request) { + return stub.getDownloadLink(request); + } + + public ListSearchableFieldsResponse listSearchableFields(ListSearchableFieldsRequest request) { + return stub.listSearchableFields(request); + } + + public CreateLabelResponse createLabel(CreateLabelRequest request) { + return stub.createLabel(request); + } +} 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..511b637 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -0,0 +1,30 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import tcnapi.exile.scrublist.v3.*; + +/** Thin wrapper around the v3 ScrubListService gRPC stub. */ +public final class ScrubListService { + + private final ScrubListServiceGrpc.ScrubListServiceBlockingStub stub; + + public ScrubListService(ManagedChannel channel) { + this.stub = ScrubListServiceGrpc.newBlockingStub(channel); + } + + public ListScrubListsResponse listScrubLists(ListScrubListsRequest request) { + return stub.listScrubLists(request); + } + + public AddEntriesResponse addEntries(AddEntriesRequest request) { + return stub.addEntries(request); + } + + public UpdateEntryResponse updateEntry(UpdateEntryRequest request) { + return stub.updateEntry(request); + } + + public RemoveEntriesResponse removeEntries(RemoveEntriesRequest request) { + return stub.removeEntries(request); + } +} 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/demo/build.gradle b/demo/build.gradle deleted file mode 100644 index 6b430f1..0000000 --- a/demo/build.gradle +++ /dev/null @@ -1,112 +0,0 @@ -plugins { - 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") -} - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - -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 - } -} - -jar { - manifest { - attributes 'Implementation-Version': project.version - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java b/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java deleted file mode 100644 index 29301c1..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java +++ /dev/null @@ -1,66 +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.demo.single; - -import com.tcn.exile.config.DiagnosticsService; -import com.tcn.exile.models.DiagnosticsResult; -import io.micronaut.context.annotation.Requires; -import io.micronaut.context.env.Environment; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Produces; -import io.micronaut.http.exceptions.HttpStatusException; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/api/admin") -@Requires(bean = Environment.class) -@OpenAPIDefinition(tags = {@Tag(name = "admin")}) -public class AdminController { - - private static final Logger log = LoggerFactory.getLogger(AdminController.class); - - @Inject private DiagnosticsService diagnosticsService; - @Inject private ConfigChangeWatcher configChangeWatcher; - - /** - * Returns system diagnostics information. - * - * @return DiagnosticsResult containing system diagnostics - */ - @Get("/diagnostics") - @Tag(name = "admin") - @Produces(MediaType.APPLICATION_JSON) - public HttpResponse getDiagnostics() { - log.info("Collecting diagnostics information"); - try { - // Then get the serializable diagnostics result - DiagnosticsResult result = diagnosticsService.collectSerdeableDiagnostics(); - return HttpResponse.ok(result); - } catch (Exception e) { - log.error("Failed to collect diagnostics information", e); - throw new HttpStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to collect diagnostics: " + e.getMessage()); - } - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java b/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java deleted file mode 100644 index eacb5b8..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java +++ /dev/null @@ -1,384 +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.demo.single; - -import build.buf.gen.tcnapi.exile.gate.v2.*; -import com.google.protobuf.StringValue; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.models.*; -import com.tcn.exile.models.Agent; -import com.tcn.exile.models.AgentState; -import com.tcn.exile.models.CallType; -import com.tcn.exile.models.ConnectedParty; -import com.tcn.exile.models.DialRequest; -import com.tcn.exile.models.DialResponse; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; -import io.micronaut.scheduling.TaskExecutors; -import io.micronaut.scheduling.annotation.ExecuteOn; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/api/agents") -@ExecuteOn(TaskExecutors.BLOCKING) -@OpenAPIDefinition(tags = {@Tag(name = "agents")}) -public class AgentsController { - private static final Logger log = LoggerFactory.getLogger(AgentsController.class); - @Inject ConfigChangeWatcher configChangeWatcher; - - @Get - @Tag(name = "agents") - public HttpResponse listAgents( - @QueryValue(value = "logged_in", defaultValue = "") Optional loggedIn, - @QueryValue(value = "state", defaultValue = "") Optional stateParam, - @QueryValue(value = "fetch_recording_status", defaultValue = "false") - boolean fetchRecordingStatus) - throws UnconfiguredException { - log.debug("listAgents with logged_in={}, state={}", loggedIn, stateParam); - - // Parse and validate state parameter - build.buf.gen.tcnapi.exile.gate.v2.AgentState stateFilter = null; - if (stateParam.isPresent() && !stateParam.get().isEmpty()) { - try { - stateFilter = parseState(stateParam.get()); - } catch (IllegalArgumentException e) { - return HttpResponse.status(HttpStatus.BAD_REQUEST) - .body( - Map.of( - "error", e.getMessage(), - "validStates", getValidStateNames())); - } - } - - // Build request with optional filters - var requestBuilder = ListAgentsRequest.newBuilder(); - - if (loggedIn.isPresent()) { - requestBuilder.setLoggedIn(loggedIn.get()); - } - - if (stateFilter != null) { - requestBuilder.setState(stateFilter); - } - - requestBuilder.setFetchRecordingStatus(fetchRecordingStatus); - - // Call gRPC service - var ret = configChangeWatcher.getGateClient().listAgents(requestBuilder.build()); - - // Collect and transform results - List agents = new ArrayList<>(); - while (ret.hasNext()) { - var agentResponse = ret.next(); - var agent = agentResponse.getAgent(); - ConnectedParty cp = null; - if (agent.hasConnectedParty()) { - cp = - new ConnectedParty( - agent.getConnectedParty().getCallSid(), - CallType.values()[agent.getConnectedParty().getCallType().getNumber()], - agent.getConnectedParty().getIsInbound()); - } - agents.add( - new Agent( - agent.getUserId(), - agent.getPartnerAgentId(), - agent.getUsername(), - agent.getFirstName(), - agent.getLastName(), - agent.getCurrentSessionId() != 0 ? agent.getCurrentSessionId() : null, - agent.getAgentState() - != build.buf.gen.tcnapi.exile.gate.v2.AgentState.AGENT_STATE_UNAVAILABLE - ? AgentState.values()[agent.getAgentState().getNumber()] - : null, - agent.getIsLoggedIn(), - fetchRecordingStatus ? agent.getIsRecording() : null, - agent.getAgentIsMuted(), - cp)); - } - - return HttpResponse.ok(agents); - } - - /** - * Parse and validate the state parameter. Accepts case-insensitive state names with or without - * "AGENT_STATE_" prefix. - * - * @param stateParam The state parameter from the request - * @return The corresponding AgentState protobuf enum - * @throws IllegalArgumentException if the state is invalid - */ - private build.buf.gen.tcnapi.exile.gate.v2.AgentState parseState(String stateParam) { - if (stateParam == null || stateParam.trim().isEmpty()) { - throw new IllegalArgumentException("State parameter cannot be empty"); - } - - // Normalize: uppercase and add prefix if not present - String normalized = stateParam.trim().toUpperCase(); - if (!normalized.startsWith("AGENT_STATE_")) { - normalized = "AGENT_STATE_" + normalized; - } - - // Try to find matching enum value - try { - return build.buf.gen.tcnapi.exile.gate.v2.AgentState.valueOf(normalized); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException( - String.format( - "Invalid state '%s'. Valid states are: %s", - stateParam, String.join(", ", getValidStateNames()))); - } - } - - /** - * Get list of valid state names (without AGENT_STATE_ prefix) for error messages. - * - * @return List of valid state names - */ - private List getValidStateNames() { - return Arrays.stream(build.buf.gen.tcnapi.exile.gate.v2.AgentState.values()) - .map(state -> state.name().replace("AGENT_STATE_", "")) - .collect(Collectors.toList()); - } - - @Post - @Consumes(MediaType.APPLICATION_JSON) - @Tag(name = "agents") - public Agent createAgent(@Body AgentUpsertRequest agent) throws UnconfiguredException { - log.debug("createAgent"); - // find - var req = UpsertAgentRequest.newBuilder().setUsername(agent.username()); - - if (agent.firstName() != null) { - req.setFirstName(agent.firstName()); - } - if (agent.lastName() != null) { - req.setLastName(agent.lastName()); - } - if (agent.partnerAgentId() != null) { - req.setPartnerAgentId(agent.partnerAgentId()); - } - if (agent.password() != null) { - req.setPassword(agent.password()); - } - var ret = configChangeWatcher.getGateClient().upsertAgent(req.build()); - if (ret != null) { - return new Agent( - ret.getAgent().getUserId(), - ret.getAgent().getPartnerAgentId(), - ret.getAgent().getUsername(), - ret.getAgent().getFirstName(), - ret.getAgent().getLastName(), - ret.getAgent().getCurrentSessionId() != 0 ? ret.getAgent().getCurrentSessionId() : null, - ret.getAgent().getAgentState() - != build.buf.gen.tcnapi.exile.gate.v2.AgentState.AGENT_STATE_UNAVAILABLE - ? AgentState.values()[ret.getAgent().getAgentState().getNumber()] - : null, - ret.getAgent().getIsLoggedIn(), - null, - null, - null); - } - throw new RuntimeException("Failed to create agent"); - } - - @Put("{partnerAgentId}/dial") - @Tag(name = "agents") - public DialResponse dial(@PathVariable String partnerAgentId, @Body DialRequest req) - throws UnconfiguredException { - log.debug("dial {}", req); - - var dialReq = - build.buf.gen.tcnapi.exile.gate.v2.DialRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setPhoneNumber(req.phoneNumber()); - if (req.callerId() != null) { - dialReq.setCallerId(StringValue.of(req.callerId())); - } - if (req.poolId() != null) { - dialReq.setPoolId(StringValue.of(req.poolId())); - } - if (req.recordId() != null) { - dialReq.setRecordId(StringValue.of(req.recordId())); - } - - var res = configChangeWatcher.getGateClient().dial(dialReq.build()); - if (res != null) { - return new DialResponse( - res.getPhoneNumber(), - res.getCallerId(), - res.getCallSid(), - CallType.valueOf(res.getCallType().name()), - res.getOrgId(), - res.getPartnerAgentId()); - } - throw new RuntimeException("Failed to dial"); - } - - @Get("{partnerAgentId}/recording") - @Tag(name = "agents") - public RecordingResponse getRecording(@PathVariable String partnerAgentId) - throws UnconfiguredException { - log.debug("getRecording"); - var res = - configChangeWatcher - .getGateClient() - .getRecordingStatus( - GetRecordingStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(res.getIsRecording()); - } - - @Put("{partnerAgentId}/recording/{status}") - @Tag(name = "agents") - public RecordingResponse setRecording( - @PathVariable String partnerAgentId, @PathVariable String status) - throws UnconfiguredException { - log.debug("setRecording"); - boolean res = false; - if (status.equalsIgnoreCase("on") - || status.equalsIgnoreCase("resume") - || status.equalsIgnoreCase("start") - || status.equalsIgnoreCase("true")) { - configChangeWatcher - .getGateClient() - .startCallRecording( - StartCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(true); - } else if (status.equalsIgnoreCase("off") - || status.equalsIgnoreCase("stop") - || status.equalsIgnoreCase("pause") - || status.equalsIgnoreCase("paused") - || status.equalsIgnoreCase("false")) { - configChangeWatcher - .getGateClient() - .stopCallRecording( - StopCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(false); - } - throw new RuntimeException("Invalid status"); - } - - @Get("{partnerAgentId}/state") - @Tag(name = "agents") - public AgentStatus getState(@PathVariable String partnerAgentId) throws UnconfiguredException { - log.debug("getState"); - var res = - configChangeWatcher - .getGateClient() - .getAgentStatus( - GetAgentStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - if (res.hasConnectedParty()) { - return new AgentStatus( - res.getPartnerAgentId(), - AgentState.values()[res.getAgentState().getNumber()], - res.getCurrentSessionId(), - new ConnectedParty( - res.getConnectedParty().getCallSid(), - CallType.values()[res.getConnectedParty().getCallType().getNumber()], - res.getConnectedParty().getIsInbound()), - res.getAgentIsMuted(), - res.getIsRecording()); - } else { - return new AgentStatus( - res.getPartnerAgentId(), - AgentState.values()[res.getAgentState().getNumber()], - res.getCurrentSessionId(), - null, - res.getAgentIsMuted(), - res.getIsRecording()); - } - } - - @Put("{partnerAgentId}/state/{state}") - @Tag(name = "agents") - public SetAgentStatusResponse setState( - @PathVariable String partnerAgentId, - @PathVariable SetAgentState state /* , @Body PauseCodeReason pauseCodeReason */) - throws UnconfiguredException { - log.debug("setState"); - var request = - UpdateAgentStatusRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setNewState(build.buf.gen.tcnapi.exile.gate.v2.AgentState.values()[state.getValue()]); - // if (pauseCodeReason != null && pauseCodeReason.reason() != null) { - // request.setReason(pauseCodeReason.reason()); - // // request.setPauseCodeReason(pauseCodeReason.reason()); - // } - var res = configChangeWatcher.getGateClient().updateAgentStatus(request.build()); - return new SetAgentStatusResponse(); - } - - @Get("{partnerAgentId}/pausecodes") - @Tag(name = "agents") - public List listPauseCodes(@PathVariable String partnerAgentId) { - log.debug("listPauseCodes"); - var res = - configChangeWatcher - .getGateClient() - .listHuntGroupPauseCodes( - ListHuntGroupPauseCodesRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .build()); - return res.getPauseCodesList().stream().toList(); - } - - @Get("{partnerAgentId}/simplehold") - @Tag(name = "agents") - public Map putCallOnSimpleHold(@PathVariable String partnerAgentId) { - var res = - configChangeWatcher - .getGateClient() - .putCallOnSimpleHold( - PutCallOnSimpleHoldRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return Map.of("success", true); - } - - @Get("{partnerAgentId}/simpleunhold") - @Tag(name = "agents") - public Map removeCallFromSimpleHold(@PathVariable String partnerAgentId) { - var res = - configChangeWatcher - .getGateClient() - .takeCallOffSimpleHold( - TakeCallOffSimpleHoldRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .build()); - return Map.of("success", true); - } - - @Put("{partnerAgentId}/callresponse") - @Tag(name = "agents") - public Map addAgentCallResponse( - @PathVariable String partnerAgentId, @Body AddAgentCallResponseRequest req) { - var res = - configChangeWatcher - .getGateClient() - .addAgentCallResponse(req.toBuilder().setPartnerAgentId(partnerAgentId).build()); - return Map.of("success", true); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/Application.java b/demo/src/main/java/com/tcn/exile/demo/single/Application.java deleted file mode 100644 index ed0595d..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/Application.java +++ /dev/null @@ -1,38 +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.demo.single; - -import io.micronaut.runtime.Micronaut; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import java.util.TimeZone; - -@OpenAPIDefinition( - info = - @Info( - title = "Sati Demo API", - version = "1.0", - description = "Demo API for Sati with Swagger UI")) -public class Application { - public static void main(String[] args) { - // Set timezone to UTC for consistency with containerized deployments - System.setProperty("user.timezone", "UTC"); - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - - Micronaut.run(Application.class, args); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java b/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java deleted file mode 100644 index 752f9e4..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java +++ /dev/null @@ -1,267 +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.demo.single; - -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.v2.GateClient; -import com.tcn.exile.gateclients.v2.GateClientConfiguration; -import com.tcn.exile.gateclients.v2.GateClientJobStream; -import com.tcn.exile.gateclients.v2.GateClientPollEvents; -import com.tcn.exile.plugin.PluginInterface; -import io.methvin.watcher.DirectoryWatcher; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.event.ApplicationEventListener; -import io.micronaut.context.event.StartupEvent; -import io.micronaut.core.type.Argument; -import io.micronaut.inject.qualifiers.Qualifiers; -import io.micronaut.scheduling.TaskScheduler; -import io.micronaut.serde.ObjectMapper; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -public class ConfigChangeWatcher implements ApplicationEventListener { - private Config currentConfig = null; - @Inject ApplicationContext context; - - @Inject ObjectMapper objectMapper; - - private static final Logger log = LoggerFactory.getLogger(ConfigChangeWatcher.class); - - private static final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg"; - private final DirectoryWatcher watcher; - - private static final List watchList = - List.of(Path.of("/workdir/config"), Path.of("workdir/config")); - - private void findValidConfigDir() { - Optional validDir = watchList.stream().filter(path -> path.toFile().exists()).findFirst(); - if (!validDir.isPresent()) { - // try to create a local workdir/config directory - var dir = Path.of("workdir/config"); - if (!dir.toFile().mkdirs()) { - log.error( - "No valid config directory found, and we don't have permissions to create one! Please create one in ./workdir/config or /workdir/config"); - } - } else { - log.info("Found valid config directory: {}", validDir.get()); - } - } - - private Optional getCurrentConfig() { - log.debug("Current config: {}", currentConfig); - Optional configDir = - watchList.stream().filter(path -> path.toFile().exists()).findFirst(); - if (configDir.isPresent()) { - if (configDir.get().resolve(Path.of(CONFIG_FILE_NAME)).toFile().exists()) { - try { - var file = configDir.get().resolve(Path.of(CONFIG_FILE_NAME)); - log.debug("Found valid config file: {}", file); - var data = Files.readAllBytes(file); - // var newConfig = Config.of(Arrays.copyOf(data, data.length-1), - // objectMapper); - var newConfig = Config.of(data, objectMapper); - if (newConfig.isPresent()) { - return newConfig; - } - } catch (IOException e) { - e.printStackTrace(); - } - } - } else { - log.debug("No valid config file found"); - } - log.debug("Return empty config"); - return Optional.empty(); - } - - public ConfigChangeWatcher() throws IOException { - log.info("creating watcher"); - // try to find valid config directories - findValidConfigDir(); - - this.watcher = - DirectoryWatcher.builder() - .paths(watchList) - .fileHashing(false) - .listener( - event -> { - log.debug("Event: {}", event); - if (!event.path().getFileName().toString().equals(CONFIG_FILE_NAME)) { - log.debug("Event path: {}", event.path()); - return; - } - - switch (event.eventType()) { - case CREATE: - case MODIFY: - if (event.path().toFile().canRead()) { - log.debug("reading config file: {}", event.path()); - var base64encodedjson = Files.readAllBytes(event.path()); - var newConfig = - Config.of( - Arrays.copyOf(base64encodedjson, base64encodedjson.length - 1), - objectMapper); - if (newConfig.isPresent()) { - log.debug("New config: {}", newConfig); - if (this.currentConfig == null) { - log.debug("Current config is null"); - // this means we will create the currentConfig - this.currentConfig = newConfig.get(); - createBeans(); - } else { - log.debug("Current config: {}", this.currentConfig); - if (!this.currentConfig.getOrg().equals(newConfig.get().getOrg())) { - // not the same org - destroyBeans(); - } else { - log.debug("New config has the same org {}", currentConfig.getOrg()); - } - this.currentConfig = newConfig.get(); - createBeans(); - } - } else { - log.debug("Can't read config file: {}", event.path()); - } - } else { - log.error("Can't read config file {}", event.path()); - } - break; - case DELETE: - if (this.currentConfig != null) { - destroyBeans(); - } - break; - default: - log.info("Unexpected event: {}", event.eventType()); - break; - } - }) - .build(); - } - - private static final String gateClientPrefix = "gate-client-"; - private static final String gateClientJobStreamPrefix = "gate-client-job-stream-"; - private static final String gateClientPollEventsPrefix = "gate-client-poll-events-"; - private static final String gateClientConfigurationPrefix = "gate-client-config-"; - private static final String pluginPrefix = "plugin-"; - - private void destroyBeans() { - context.destroyBean( - Argument.of(GateClient.class), - Qualifiers.byName(gateClientPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientJobStream.class), - Qualifiers.byName(gateClientJobStreamPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientPollEvents.class), - Qualifiers.byName(gateClientPollEventsPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientConfiguration.class), - Qualifiers.byName(gateClientConfigurationPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(PluginInterface.class), - Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg())); - } - - private void createBeans() { - log.info("creating bean for org {}", this.currentConfig.getOrg()); - var tenantKey = this.currentConfig.getOrg(); - var gateClient = new GateClient(tenantKey, this.currentConfig); - var demoPlugin = new DemoPlugin(tenantKey, gateClient, context); - var gateClientJobStream = new GateClientJobStream(tenantKey, this.currentConfig, demoPlugin); - - var gateClientPollEvents = new GateClientPollEvents(tenantKey, this.currentConfig, demoPlugin); - var gateClientConfiguration = - new GateClientConfiguration(tenantKey, this.currentConfig, demoPlugin); - - context.registerSingleton( - GateClient.class, - gateClient, - Qualifiers.byName(gateClientPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientJobStream.class, - gateClientJobStream, - Qualifiers.byName(gateClientJobStreamPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientPollEvents.class, - gateClientPollEvents, - Qualifiers.byName(gateClientPollEventsPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientConfiguration.class, - gateClientConfiguration, - Qualifiers.byName(gateClientConfigurationPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - PluginInterface.class, - demoPlugin, - Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg()), - true); - TaskScheduler taskScheduler = context.getBean(TaskScheduler.class); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(1), gateClientJobStream::start); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(10), gateClientPollEvents::start); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(30), gateClientConfiguration::start); - log.debug( - "test client-pool-events bean is created: {}", - context - .findBean( - GateClientPollEvents.class, - Qualifiers.byName("gate-client-poll-events-" + this.currentConfig.getOrg())) - .isPresent()); - } - - @Override - public void onApplicationEvent(StartupEvent event) { - log.info("Starting config change watcher"); - getCurrentConfig() - .ifPresent( - config -> { - log.info("Current config: {}", config); - this.currentConfig = config; - createBeans(); - }); - this.watcher.watchAsync(); - } - - public GateClient getGateClient() { - if (this.currentConfig == null) { - throw new IllegalStateException("No current config loaded"); - } - String beanName = gateClientPrefix + this.currentConfig.getOrg(); - return context.getBean(GateClient.class, Qualifiers.byName(beanName)); - } - - public PluginInterface getPlugin() { - return context.getBean( - PluginInterface.class, Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg())); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java deleted file mode 100644 index 11d5b7e..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java +++ /dev/null @@ -1,593 +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.demo.single; - -import build.buf.gen.tcnapi.exile.gate.v2.ExileAgentCall; -import build.buf.gen.tcnapi.exile.gate.v2.ExileAgentResponse; -import build.buf.gen.tcnapi.exile.gate.v2.ExileTask; -import build.buf.gen.tcnapi.exile.gate.v2.ExileTelephonyResult; -import build.buf.gen.tcnapi.exile.gate.v2.LogRequest; -import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse; -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest; -import ch.qos.logback.classic.LoggerContext; -import com.tcn.exile.config.DiagnosticsService; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.gateclients.v2.GateClient; -import com.tcn.exile.memlogger.LogShipper; -import com.tcn.exile.memlogger.MemoryAppenderInstance; -import com.tcn.exile.models.PluginConfigEvent; -import com.tcn.exile.plugin.PluginInterface; -import com.tcn.exile.plugin.PluginStatus; -import io.micronaut.context.ApplicationContext; -import java.net.InetAddress; -import java.util.HashMap; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class DemoPlugin implements PluginInterface, LogShipper { - private static final Logger log = LoggerFactory.getLogger(DemoPlugin.class); - private boolean running = false; - - GateClient gateClient; - private PluginConfigEvent pluginConfig; - private String tenantKey; - private DiagnosticsService diagnosticsService; - - public DemoPlugin( - String tenantKey, GateClient gateClient, ApplicationContext applicationContext) { - this.gateClient = gateClient; - this.running = true; - this.tenantKey = tenantKey; - this.diagnosticsService = new DiagnosticsService(applicationContext); - } - - @Override - public String getName() { - return "DemoPlugin"; - } - - @Override - public boolean isRunning() { - return running; - } - - @Override - public PluginStatus getPluginStatus() { - return new PluginStatus( - getName(), - running, - 100, // queueMaxSize - 0, // queueCompletedJobs - 0, // queueActiveCount - new HashMap<>(), // internalConfig - new HashMap<>() // internalStatus - ); - } - - @Override - public void listPools(String jobId, StreamJobsResponse.ListPoolsRequest listPools) - throws UnconfiguredException { - log.info("Tenant: {} - Listing pools for job {}", tenantKey, jobId); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListPoolsResult( - SubmitJobResultsRequest.ListPoolsResult.newBuilder() - .addPools( - build.buf.gen.tcnapi.exile.core.v2.Pool.newBuilder() - .setPoolId("A") - .setDescription("Pool with id A") - .setStatus(build.buf.gen.tcnapi.exile.core.v2.Pool.PoolStatus.READY) - .build()) - .build()) - .build()); - } - - @Override - public void getPoolStatus(String jobId, StreamJobsResponse.GetPoolStatusRequest request) - throws UnconfiguredException { - log.info("Tenant: {} - Getting pool status for job={} and pool={}", tenantKey, jobId, request); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetPoolStatusResult( - SubmitJobResultsRequest.GetPoolStatusResult.newBuilder() - .setPool( - build.buf.gen.tcnapi.exile.core.v2.Pool.newBuilder() - .setPoolId(request.getPoolId()) - .setStatus(build.buf.gen.tcnapi.exile.core.v2.Pool.PoolStatus.READY) - .build()) - .build()) - .build()); - } - - @Override - public void getPoolRecords(String jobId, StreamJobsResponse.GetPoolRecordsRequest request) - throws UnconfiguredException { - log.info("Tenant: {} - Getting pool records for job {} and pool {}", tenantKey, jobId, request); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetPoolRecordsResult( - SubmitJobResultsRequest.GetPoolRecordsResult.newBuilder() - .addRecords( - build.buf.gen.tcnapi.exile.core.v2.Record.newBuilder() - .setPoolId(request.getPoolId()) - .setRecordId("blue") - .setJsonRecordPayload("{\"f1\": \"foo\"}") - .build()) - .addRecords( - build.buf.gen.tcnapi.exile.core.v2.Record.newBuilder() - .setPoolId(request.getPoolId()) - .setRecordId("red") - .setJsonRecordPayload("{\"f2\": \"bar\"}") - .build()) - .build()) - .build()); - } - - @Override - public void handleAgentCall(ExileAgentCall exileAgentCall) { - log.info("Tenant: {} - Handling agent call for job {}", tenantKey, exileAgentCall); - } - - @Override - public void handleTelephonyResult(ExileTelephonyResult exileTelephonyResult) { - log.info("Tenant: {} - Handling telephony result for job {}", tenantKey, exileTelephonyResult); - } - - @Override - public void handleTask(ExileTask exileTask) { - log.info("Tenant: {} - Handling task for job {}", tenantKey, exileTask); - } - - @Override - public void handleAgentResponse(ExileAgentResponse exileAgentResponse) { - log.info("Tenant: {} - Handling agent response for {}", tenantKey, exileAgentResponse); - } - - @Override - public void handleTransferInstance( - build.buf.gen.tcnapi.exile.gate.v2.ExileTransferInstance exileTransferInstance) { - log.info("Tenant: {} - Handling transfer instance for {}", tenantKey, exileTransferInstance); - } - - @Override - public void handleCallRecording( - build.buf.gen.tcnapi.exile.gate.v2.ExileCallRecording exileCallRecording) { - log.info( - "Tenant: {} - Handling call recording for {}", - tenantKey, - exileCallRecording.getRecordingId()); - } - - @Override - public void searchRecords(String jobId, StreamJobsResponse.SearchRecordsRequest searchRecords) {} - - @Override - public void readFields(String jobId, StreamJobsResponse.GetRecordFieldsRequest getRecordFields) { - log.info( - "Tenant: {} - Reading fields for job {} and record {}", - tenantKey, - jobId, - getRecordFields.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetRecordFieldsResult( - SubmitJobResultsRequest.GetRecordFieldsResult.newBuilder() - .addFields( - build.buf.gen.tcnapi.exile.core.v2.Field.newBuilder() - .setFieldName("foo") - .setFieldValue("bar") - .setRecordId(getRecordFields.getRecordId()) - .build()) - .build()) - .build()); - } - - @Override - public void writeFields(String jobId, StreamJobsResponse.SetRecordFieldsRequest setRecordFields) { - log.info( - "Tenant: {} - Writing fields for job {} and record {}", - tenantKey, - jobId, - setRecordFields.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetRecordFieldsResult( - SubmitJobResultsRequest.SetRecordFieldsResult.newBuilder().build()) - .build()); - } - - @Override - public void createPayment(String jobId, StreamJobsResponse.CreatePaymentRequest createPayment) { - log.info( - "Tenant: {} - Creating payment for job {} and record {}", - tenantKey, - jobId, - createPayment.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setCreatePaymentResult( - SubmitJobResultsRequest.CreatePaymentResult.newBuilder().build()) - .build()); - } - - @Override - public void popAccount(String jobId, StreamJobsResponse.PopAccountRequest popAccount) { - log.info( - "Tenant: {} - Popping account for job {} and record {}", - tenantKey, - jobId, - popAccount.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setPopAccountResult(SubmitJobResultsRequest.PopAccountResult.newBuilder().build()) - .build()); - } - - private String getServerName() { - try { - return InetAddress.getLocalHost().getHostName(); - } catch (Exception e) { - return "Unknown"; - } - } - - private String getVersion() { - var ret = this.getClass().getPackage().getImplementationVersion(); - return ret == null ? "Unknown" : ret; - } - - @Override - public void info(String jobId, StreamJobsResponse.InfoRequest info) { - log.info("Tenant: {} - Info for job {}", tenantKey, jobId); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setInfoResult( - SubmitJobResultsRequest.InfoResult.newBuilder() - .setServerName(getServerName()) - .setCoreVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginName("DemoPlugin") - .setPluginVersion(getVersion()) - .build()) - .build()); - } - - public SubmitJobResultsRequest.InfoResult info() { - return SubmitJobResultsRequest.InfoResult.newBuilder() - .setServerName(getServerName()) - .setCoreVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginName("DemoPlugin") - .setPluginVersion(getVersion()) - .build(); - } - - @Override - public void shutdown(String jobId, StreamJobsResponse.SeppukuRequest shutdown) { - log.warn("Tenant: {} - Seppuku requested for job: {}", tenantKey, jobId); - - // Try to submit acknowledgment, but don't let failure prevent shutdown - try { - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setShutdownResult(SubmitJobResultsRequest.SeppukuResult.newBuilder().build()) - .build()); - log.warn( - "Tenant: {} - Seppuku acknowledged for job: {}, initiating graceful shutdown", - tenantKey, - jobId); - } catch (Exception e) { - log.warn( - "Tenant: {} - Failed to acknowledge seppuku for job: {}, but proceeding with shutdown anyway: {}", - tenantKey, - jobId, - e.getMessage()); - } - - // Execute shutdown in separate thread to allow any gRPC response to be sent - try { - Thread shutdownThread = - new Thread( - () -> { - try { - log.warn( - "Tenant: {} - Shutdown thread started, waiting 2 seconds before termination", - tenantKey); - - // Give time for any response to be sent back to the server - Thread.sleep(2000); - - log.warn("Tenant: {} - Executing seppuku - terminating application", tenantKey); - - // Perform graceful shutdown - System.exit(0); - - } catch (InterruptedException e) { - log.error( - "Tenant: {} - Seppuku interrupted, forcing immediate shutdown", tenantKey); - Thread.currentThread().interrupt(); - System.exit(1); - } catch (Exception e) { - log.error( - "Tenant: {} - Unexpected error during seppuku, forcing immediate shutdown: {}", - tenantKey, - e.getMessage()); - System.exit(1); - } - }, - "SeppukuThread-" + tenantKey); - - shutdownThread.setDaemon(false); // Ensure JVM waits for this thread - shutdownThread.start(); - - log.warn("Tenant: {} - Seppuku thread started, shutdown sequence initiated", tenantKey); - } catch (Exception e) { - log.error( - "Tenant: {} - Failed to start shutdown thread, forcing immediate shutdown: {}", - tenantKey, - e.getMessage()); - // If we can't even start the thread, exit immediately - System.exit(1); - } - } - - @Override - public void logger(String jobId, StreamJobsResponse.LoggingRequest logRequest) { - log.debug( - "Tenant: {} - Received log request {} stream {} payload: {}", - tenantKey, - jobId, - logRequest.getStreamLogs(), - logRequest.getLoggerLevelsList()); - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - - for (var logger : logRequest.getLoggerLevelsList()) { - log.debug( - "Tenant: {} - Setting logger {} to level {}", - tenantKey, - logger.getLoggerName(), - logger.getLoggerLevel()); - var v = loggerContext.getLogger(logger.getLoggerName()); - if (v != null) { - if (logger.getLoggerLevel() - == StreamJobsResponse.LoggingRequest.LoggerLevel.Level.DISABLED) { - v.setLevel(ch.qos.logback.classic.Level.OFF); - } else { - v.setLevel(ch.qos.logback.classic.Level.toLevel(logger.getLoggerLevel().name())); - } - } else { - log.warn("Tenant: {} - Logger {} not found", tenantKey, logger.getLoggerName()); - } - } - if (logRequest.getStreamLogs()) { - MemoryAppenderInstance.getInstance().enableLogShipper(this); - } else { - MemoryAppenderInstance.getInstance().disableLogShipper(); - } - - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setLoggingResult(SubmitJobResultsRequest.LoggingResult.newBuilder().build()) - .build()); - } - - /** Helper method to list all available loggers for debugging */ - private void listAvailableLoggers() { - try { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - log.info("Tenant: {} - Available loggers:", tenantKey); - - // List some common loggers - for (ch.qos.logback.classic.Logger logger : loggerContext.getLoggerList()) { - if (logger.getLevel() != null - || logger.getName().contains("tcn") - || logger.getName().contains("exile") - || logger.getName().equals("ROOT")) { - log.info( - "Tenant: {} - Logger: '{}' - Level: {}", - tenantKey, - logger.getName(), - logger.getLevel() != null ? logger.getLevel().toString() : "INHERITED"); - } - } - } catch (Exception e) { - log.warn("Tenant: {} - Error listing loggers: {}", tenantKey, e.getMessage()); - } - } - - @Override - public void executeLogic(String jobId, StreamJobsResponse.ExecuteLogicRequest executeLogic) {} - - @Override - public void setConfig(PluginConfigEvent config) { - this.pluginConfig = config; - if (this.pluginConfig == null) { - this.running = false; - } - if (config.isUnconfigured()) { - running = false; - } - running = true; - } - - @Override - public void shipLogs(List payload) { - log.info("Tenant: {} - Ship logs", tenantKey); - if (payload == null || payload.isEmpty()) { - return; - } - String combinedPayload = String.join("\n", payload); - gateClient.log(LogRequest.newBuilder().setPayload(combinedPayload).build()); - } - - @Override - public void stop() { - log.info("Tenant: {} - Stopping shipping logs plugin", tenantKey); - MemoryAppenderInstance.getInstance().disableLogShipper(); - } - - @Override - public void runDiagnostics( - String jobId, StreamJobsResponse.DiagnosticsRequest diagnosticsRequest) { - log.info("Tenant: {} - Running diagnostics for job {}", tenantKey, jobId); - - try { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult diagnostics = - null; - if (diagnosticsService != null) { - // Then collect system diagnostics for the response - diagnostics = diagnosticsService.collectSystemDiagnostics(); - } else { - log.warn("DiagnosticsService is null, cannot collect system diagnostics"); - // Create empty diagnostics result if service is unavailable - diagnostics = SubmitJobResultsRequest.DiagnosticsResult.newBuilder().build(); - } - - // Submit diagnostics results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setDiagnosticsResult(diagnostics) - .build()); - } catch (Exception e) { - log.error("Error running diagnostics", e); - // Return empty diagnostics result on error - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setDiagnosticsResult(SubmitJobResultsRequest.DiagnosticsResult.newBuilder().build()) - .build()); - } - } - - @Override - public void listTenantLogs( - String jobId, StreamJobsResponse.ListTenantLogsRequest listTenantLogsRequest) { - log.info("Tenant: {} - Listing tenant logs for job {}", tenantKey, jobId); - - try { - // Use DiagnosticsService to collect tenant logs with time range filtering - SubmitJobResultsRequest.ListTenantLogsResult tenantLogsResult = - diagnosticsService.collectTenantLogs(listTenantLogsRequest); - - // Submit tenant logs results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListTenantLogsResult(tenantLogsResult) - .build()); - } catch (Exception e) { - log.error("Error listing tenant logs for job {}: {}", jobId, e.getMessage(), e); - - // Return empty log result on error - try { - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListTenantLogsResult( - SubmitJobResultsRequest.ListTenantLogsResult.newBuilder().build()) - .build()); - log.debug("Submitted empty result for failed job: {}", jobId); - } catch (Exception submitError) { - log.error( - "Failed to submit error result for job {}: {}", - jobId, - submitError.getMessage(), - submitError); - } - } - } - - @Override - public void setLogLevel(String jobId, StreamJobsResponse.SetLogLevelRequest setLogLevelRequest) { - log.info( - "Tenant: {} - Setting log level for job {} and logger {} to level {}", - tenantKey, - jobId, - setLogLevelRequest.getLog(), - setLogLevelRequest.getLogLevel()); - - try { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - setLogLevelResult = null; - if (diagnosticsService != null) { - setLogLevelResult = diagnosticsService.setLogLevelWithTenant(setLogLevelRequest, tenantKey); - } else { - log.warn("DiagnosticsService is null, cannot set log level"); - - java.time.Instant now = java.time.Instant.now(); - com.google.protobuf.Timestamp updateTime = - com.google.protobuf.Timestamp.newBuilder() - .setSeconds(now.getEpochSecond()) - .setNanos(now.getNano()) - .build(); - - SubmitJobResultsRequest.SetLogLevelResult.Tenant tenant = - SubmitJobResultsRequest.SetLogLevelResult.Tenant.newBuilder() - .setName(tenantKey) - .setSatiVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginVersion(getVersion()) - .setUpdateTime(updateTime) - .setConnectedGate(getServerName()) - .build(); - - setLogLevelResult = - SubmitJobResultsRequest.SetLogLevelResult.newBuilder().setTenant(tenant).build(); - } - - // Submit results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetLogLevelResult(setLogLevelResult) - .build()); - } catch (Exception e) { - log.error("Error setting log level for job {}: {}", jobId, e.getMessage(), e); - // Return empty result on error - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetLogLevelResult(SubmitJobResultsRequest.SetLogLevelResult.newBuilder().build()) - .build()); - } - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java b/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java deleted file mode 100644 index 891a123..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java +++ /dev/null @@ -1,87 +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.demo.single; - -import ch.qos.logback.classic.LoggerContext; -import com.tcn.exile.memlogger.MemoryAppender; -import com.tcn.exile.memlogger.MemoryAppenderInstance; -import io.micronaut.context.annotation.Requires; -import io.micronaut.context.env.Environment; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Produces; -import io.micronaut.serde.ObjectMapper; -import jakarta.inject.Inject; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/logs") -@Requires(bean = Environment.class) -public class LogsController { - - static final String LOGGER_PROPERTY_PREFIX = "logger"; - static final String LOGGER_LEVELS_PROPERTY_PREFIX = LOGGER_PROPERTY_PREFIX + ".levels"; - private static final Logger log = LoggerFactory.getLogger(LogsController.class); - - @Inject ObjectMapper objectMapper; - - @Inject Environment environment; - - @Get - @Produces(MediaType.APPLICATION_JSON) - public List index() throws IOException { - MemoryAppender instance = MemoryAppenderInstance.getInstance(); - if (instance == null) { - return new ArrayList<>(); - } - return List.of(); // instance.getEvents(); - } - - @Get("/loggers") - public Map loggers() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - Map loggers = new HashMap<>(); - for (var logger : loggerContext.getLoggerList()) { - - if (logger.getLevel() == null) { - loggers.put(logger.getName(), "null"); - continue; - } - loggers.put(logger.getName(), logger.getLevel().levelStr); - } - return loggers; - } - - @Get("/loggers/{logger}/level/{level}") - public String setLoggerLevel(String logger, String level) { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - var loggers = loggerContext.getLoggerList(); - for (var l : loggers) { - if (l.getName().equals(logger)) { - l.setLevel(ch.qos.logback.classic.Level.toLevel(level)); - return "OK"; - } - } - return "Logger not found"; - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java b/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java deleted file mode 100644 index d9614e0..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java +++ /dev/null @@ -1,33 +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.demo.single; - -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import jakarta.inject.Inject; - -@Controller("/version") -public class VersionController { - @Inject ConfigChangeWatcher configChangeWatcher; - - @Get - public VersionInfo index() { - var ver = configChangeWatcher.getPlugin().info(); - return new VersionInfo( - ver.getCoreVersion(), ver.getServerName(), ver.getPluginVersion(), ver.getPluginName()); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java b/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java deleted file mode 100644 index 082f4ec..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java +++ /dev/null @@ -1,23 +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.demo.single; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record VersionInfo( - String coreVersion, String serverName, String pluginVersion, String pluginName) {} diff --git a/demo/src/main/resources/META-INF/openapi.properties b/demo/src/main/resources/META-INF/openapi.properties deleted file mode 100644 index ec10ef3..0000000 --- a/demo/src/main/resources/META-INF/openapi.properties +++ /dev/null @@ -1 +0,0 @@ -swagger-ui.enabled=true \ No newline at end of file diff --git a/demo/src/main/resources/application.yml b/demo/src/main/resources/application.yml deleted file mode 100644 index 02368ae..0000000 --- a/demo/src/main/resources/application.yml +++ /dev/null @@ -1,49 +0,0 @@ -micronaut: - control-panel: - enabled: true - allowed-environments: dev - application: - name: sati-demo - views: - dir: views - router: - static-resources: - enabled: true - css: - mapping: /css/*.css - paths: classpath:static/css - images: - mapping: /images/** - paths: classpath:static/images - swagger: - paths: classpath:META-INF/swagger - mapping: /swagger/** - swagger-ui: - paths: classpath:META-INF/swagger/views/swagger-ui - mapping: /swagger-ui/** - server: - port: 8080 - -# Disable gRPC server health check since we don't need an embedded gRPC server -endpoints: - health: - enabled: true - details-visible: ANONYMOUS - grpc: - enabled: false - -logger: - levels: - com.tcn.exile: DEBUG - com.zaxxer.hikari: DEBUG - io.micronaut.http.client: TRACE - -http: - server: - enabled: true - - -# Enable single tenant mode -sati: - tenant: - type: single \ No newline at end of file diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml deleted file mode 100644 index c713892..0000000 --- a/demo/src/main/resources/logback.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n - - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - true - - - - - - - - diff --git a/gradle.properties b/gradle.properties index ef47cf4..055b33e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,3 @@ -micronautVersion=4.7.6 -micronautGradlePluginVersion=4.4.4 - grpcVersion=1.68.1 protobufVersion=3.25.5 exileapiProtobufVersion=34.1.0.1.20260323182149.1e342050752a diff --git a/settings.gradle b/settings.gradle index 2b0c278..8c42a77 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,15 +1,3 @@ - -// pluginManagement { -// repositories { -// // releases -// mavenCentral() -// // snapshots -// maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } -// // Once you start using pluginManagement, you should explicitly add this, unless -// // you NEVER want to use this repository -// gradlePluginPortal() -// } -// } rootProject.name="sati" -include('core', 'logback-ext', 'demo') +include('core', 'logback-ext') From bca52f37613e45972c3bf8363e03555a164fae91 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:34:48 -0600 Subject: [PATCH 02/50] Hide protobuf types behind plain Java model layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No protobuf generated interfaces are exposed in the public API. Integrations only see plain Java records, enums, and java.time types. Added: - model/ package: Pool, Record, Field, Filter, Agent, Skill, CallType, AgentState, TaskData, Page — all Java records - model/event/ package: AgentCallEvent, TelephonyResultEvent, AgentResponseEvent, TransferInstanceEvent, CallRecordingEvent, TaskEvent — Java records with java.time.Duration/Instant fields - internal/ProtoConverter: bidirectional conversion between proto types and the Java model. Only class that touches generated proto classes. - service/ServiceFactory: creates service instances with package-private constructors so ManagedChannel doesn't leak Changed: - JobHandler: methods take/return plain Java types (List, Page, Map) instead of proto messages - EventHandler: methods take event records instead of proto messages - All 5 service clients: public methods use Java types only, constructors are package-private - WorkStreamClient: converts between proto and model at the boundary Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/tcn/exile/ExileClient.java | 11 +- .../com/tcn/exile/handler/EventHandler.java | 14 +- .../com/tcn/exile/handler/JobHandler.java | 62 +-- .../tcn/exile/internal/ProtoConverter.java | 386 ++++++++++++++++++ .../tcn/exile/internal/WorkStreamClient.java | 248 ++++++----- .../main/java/com/tcn/exile/model/Agent.java | 20 + .../java/com/tcn/exile/model/AgentState.java | 23 ++ .../java/com/tcn/exile/model/CallType.java | 10 + .../main/java/com/tcn/exile/model/Field.java | 3 + .../main/java/com/tcn/exile/model/Filter.java | 15 + .../main/java/com/tcn/exile/model/Page.java | 11 + .../main/java/com/tcn/exile/model/Pool.java | 11 + .../main/java/com/tcn/exile/model/Record.java | 5 + .../main/java/com/tcn/exile/model/Skill.java | 5 + .../java/com/tcn/exile/model/TaskData.java | 3 + .../tcn/exile/model/event/AgentCallEvent.java | 30 ++ .../exile/model/event/AgentResponseEvent.java | 19 + .../exile/model/event/CallRecordingEvent.java | 14 + .../com/tcn/exile/model/event/TaskEvent.java | 15 + .../model/event/TelephonyResultEvent.java | 28 ++ .../model/event/TransferInstanceEvent.java | 34 ++ .../com/tcn/exile/service/AgentService.java | 119 ++++-- .../com/tcn/exile/service/CallService.java | 95 ++++- .../com/tcn/exile/service/ConfigService.java | 54 ++- .../tcn/exile/service/RecordingService.java | 78 +++- .../tcn/exile/service/ScrubListService.java | 56 ++- .../com/tcn/exile/service/ServiceFactory.java | 24 ++ 27 files changed, 1181 insertions(+), 212 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/internal/ProtoConverter.java create mode 100644 core/src/main/java/com/tcn/exile/model/Agent.java create mode 100644 core/src/main/java/com/tcn/exile/model/AgentState.java create mode 100644 core/src/main/java/com/tcn/exile/model/CallType.java create mode 100644 core/src/main/java/com/tcn/exile/model/Field.java create mode 100644 core/src/main/java/com/tcn/exile/model/Filter.java create mode 100644 core/src/main/java/com/tcn/exile/model/Page.java create mode 100644 core/src/main/java/com/tcn/exile/model/Pool.java create mode 100644 core/src/main/java/com/tcn/exile/model/Record.java create mode 100644 core/src/main/java/com/tcn/exile/model/Skill.java create mode 100644 core/src/main/java/com/tcn/exile/model/TaskData.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/TaskEvent.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java create mode 100644 core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java create mode 100644 core/src/main/java/com/tcn/exile/service/ServiceFactory.java diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 1abf4a1..c80ce9e 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -73,11 +73,12 @@ private ExileClient(Builder builder) { // Create a shared channel for unary RPCs. this.serviceChannel = ChannelFactory.create(config); - this.agentService = new AgentService(serviceChannel); - this.callService = new CallService(serviceChannel); - this.recordingService = new RecordingService(serviceChannel); - this.scrubListService = new ScrubListService(serviceChannel); - this.configService = new ConfigService(serviceChannel); + 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(); } /** Start the work stream. Call this after building the client. */ diff --git a/core/src/main/java/com/tcn/exile/handler/EventHandler.java b/core/src/main/java/com/tcn/exile/handler/EventHandler.java index ca217a7..745bd73 100644 --- a/core/src/main/java/com/tcn/exile/handler/EventHandler.java +++ b/core/src/main/java/com/tcn/exile/handler/EventHandler.java @@ -1,6 +1,6 @@ package com.tcn.exile.handler; -import tcnapi.exile.types.v3.*; +import com.tcn.exile.model.event.*; /** * Handles events dispatched by the gate server. Events are informational — the server only needs an @@ -14,15 +14,15 @@ */ public interface EventHandler { - default void onAgentCall(AgentCall call) throws Exception {} + default void onAgentCall(AgentCallEvent event) throws Exception {} - default void onTelephonyResult(TelephonyResult result) throws Exception {} + default void onTelephonyResult(TelephonyResultEvent event) throws Exception {} - default void onAgentResponse(AgentResponse response) throws Exception {} + default void onAgentResponse(AgentResponseEvent event) throws Exception {} - default void onTransferInstance(TransferInstance transfer) throws Exception {} + default void onTransferInstance(TransferInstanceEvent event) throws Exception {} - default void onCallRecording(CallRecording recording) throws Exception {} + default void onCallRecording(CallRecordingEvent event) throws Exception {} - default void onTask(Task task) 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 index 3d94bf1..6d7425f 100644 --- a/core/src/main/java/com/tcn/exile/handler/JobHandler.java +++ b/core/src/main/java/com/tcn/exile/handler/JobHandler.java @@ -1,14 +1,13 @@ package com.tcn.exile.handler; -import tcnapi.exile.worker.v3.*; +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 a task payload and returns the - * corresponding result. The {@link com.tcn.exile.internal.WorkStreamClient} sends the result back - * to the server automatically. - * - *

Implementations should throw an exception if the job cannot be processed. The stream client - * will submit an {@code ErrorResult} with the exception message and nack the work item. + * 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. * @@ -17,63 +16,80 @@ */ public interface JobHandler { - default ListPoolsResult listPools(ListPoolsTask task) throws Exception { + default List listPools() throws Exception { throw new UnsupportedOperationException("listPools not implemented"); } - default GetPoolStatusResult getPoolStatus(GetPoolStatusTask task) throws Exception { + default Pool getPoolStatus(String poolId) throws Exception { throw new UnsupportedOperationException("getPoolStatus not implemented"); } - default GetPoolRecordsResult getPoolRecords(GetPoolRecordsTask task) throws Exception { + default Page getPoolRecords(String poolId, String pageToken, int pageSize) + throws Exception { throw new UnsupportedOperationException("getPoolRecords not implemented"); } - default SearchRecordsResult searchRecords(SearchRecordsTask task) throws Exception { + default Page searchRecords(List filters, String pageToken, int pageSize) + throws Exception { throw new UnsupportedOperationException("searchRecords not implemented"); } - default GetRecordFieldsResult getRecordFields(GetRecordFieldsTask task) throws Exception { + default List getRecordFields(String poolId, String recordId, List fieldNames) + throws Exception { throw new UnsupportedOperationException("getRecordFields not implemented"); } - default SetRecordFieldsResult setRecordFields(SetRecordFieldsTask task) throws Exception { + default boolean setRecordFields(String poolId, String recordId, List fields) + throws Exception { throw new UnsupportedOperationException("setRecordFields not implemented"); } - default CreatePaymentResult createPayment(CreatePaymentTask task) throws Exception { + default String createPayment(String poolId, String recordId, Map paymentData) + throws Exception { throw new UnsupportedOperationException("createPayment not implemented"); } - default PopAccountResult popAccount(PopAccountTask task) throws Exception { + default Record popAccount(String poolId, String recordId) throws Exception { throw new UnsupportedOperationException("popAccount not implemented"); } - default ExecuteLogicResult executeLogic(ExecuteLogicTask task) throws Exception { + default Map executeLogic(String logicName, Map parameters) + throws Exception { throw new UnsupportedOperationException("executeLogic not implemented"); } - default InfoResult info(InfoTask task) throws Exception { + /** Return client info. Keys: appName, appVersion, plus any custom metadata. */ + default Map info() throws Exception { throw new UnsupportedOperationException("info not implemented"); } - default ShutdownResult shutdown(ShutdownTask task) throws Exception { + default void shutdown(String reason) throws Exception { throw new UnsupportedOperationException("shutdown not implemented"); } - default LoggingResult logging(LoggingTask task) throws Exception { - throw new UnsupportedOperationException("logging not implemented"); + default void processLog(String payload) throws Exception { + throw new UnsupportedOperationException("processLog not implemented"); } - default DiagnosticsResult diagnostics(DiagnosticsTask task) throws Exception { + /** Return system diagnostics as structured sections. */ + default DiagnosticsInfo diagnostics() throws Exception { throw new UnsupportedOperationException("diagnostics not implemented"); } - default ListTenantLogsResult listTenantLogs(ListTenantLogsTask task) throws Exception { + default Page listTenantLogs(Instant startTime, Instant endTime, String pageToken, + int pageSize) throws Exception { throw new UnsupportedOperationException("listTenantLogs not implemented"); } - default SetLogLevelResult setLogLevel(SetLogLevelTask task) throws Exception { + 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/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java new file mode 100644 index 0000000..178ac07 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java @@ -0,0 +1,386 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.model.*; +import com.tcn.exile.model.Record; +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(tcnapi.exile.types.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(tcnapi.exile.types.v3.AgentState as) { + try { + return AgentState.valueOf(as.name().replace("AGENT_STATE_", "")); + } catch (IllegalArgumentException e) { + return AgentState.UNSPECIFIED; + } + } + + public static tcnapi.exile.types.v3.AgentState fromAgentState(AgentState as) { + try { + return tcnapi.exile.types.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); + } catch (IllegalArgumentException e) { + return tcnapi.exile.types.v3.AgentState.AGENT_STATE_UNSPECIFIED; + } + } + + // ---- Core types ---- + + public static Pool toPool(tcnapi.exile.types.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 tcnapi.exile.types.v3.Pool fromPool(Pool p) { + return tcnapi.exile.types.v3.Pool.newBuilder() + .setPoolId(p.poolId()) + .setDescription(p.description()) + .setStatus( + switch (p.status()) { + case READY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_READY; + case NOT_READY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY; + case BUSY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_BUSY; + default -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED; + }) + .setRecordCount(p.recordCount()) + .build(); + } + + public static Record toRecord(tcnapi.exile.types.v3.Record r) { + return new Record(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload())); + } + + public static tcnapi.exile.types.v3.Record fromRecord(Record r) { + return tcnapi.exile.types.v3.Record.newBuilder() + .setPoolId(r.poolId()) + .setRecordId(r.recordId()) + .setPayload(mapToStruct(r.payload())) + .build(); + } + + public static Field toField(tcnapi.exile.types.v3.Field f) { + return new Field(f.getFieldName(), f.getFieldValue(), f.getPoolId(), f.getRecordId()); + } + + public static tcnapi.exile.types.v3.Field fromField(Field f) { + return tcnapi.exile.types.v3.Field.newBuilder() + .setFieldName(f.fieldName()) + .setFieldValue(f.fieldValue()) + .setPoolId(f.poolId()) + .setRecordId(f.recordId()) + .build(); + } + + public static Filter toFilter(tcnapi.exile.types.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 tcnapi.exile.types.v3.Filter fromFilter(Filter f) { + return tcnapi.exile.types.v3.Filter.newBuilder() + .setField(f.field()) + .setOperator( + switch (f.operator()) { + case EQUAL -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_EQUAL; + case NOT_EQUAL -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_NOT_EQUAL; + case CONTAINS -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_CONTAINS; + case GREATER_THAN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_GREATER_THAN; + case LESS_THAN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_LESS_THAN; + case IN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_IN; + case EXISTS -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_EXISTS; + default -> tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.v3.Task 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 index a8e1105..e5db12d 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -1,19 +1,23 @@ package com.tcn.exile.internal; +import static com.tcn.exile.internal.ProtoConverter.*; + import com.tcn.exile.ExileConfig; 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 java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Map; 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.AtomicReference; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tcnapi.exile.worker.v3.*; @@ -21,12 +25,8 @@ /** * Implements the v3 WorkStream protocol over a single bidirectional gRPC stream. * - *

Lifecycle: {@link #start()} opens the stream in a background thread that reconnects - * automatically. {@link #close()} shuts everything down. - * - *

The client uses credit-based flow control: it sends {@code Pull(max_items=N)} to control how - * many concurrent work items it processes. Work items are dispatched to virtual threads, so the - * main stream thread never blocks on handler execution. + *

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 { @@ -66,16 +66,12 @@ public WorkStreamClient( this.capabilities = capabilities; } - /** Start the work stream in a background thread. Returns immediately. */ public void start() { if (!running.compareAndSet(false, true)) { throw new IllegalStateException("Already started"); } streamThread = - Thread.ofPlatform() - .name("exile-work-stream") - .daemon(true) - .start(this::reconnectLoop); + Thread.ofPlatform().name("exile-work-stream").daemon(true).start(this::reconnectLoop); } private void reconnectLoop() { @@ -84,14 +80,13 @@ private void reconnectLoop() { try { backoff.sleep(); runStream(); - // Stream ended normally (server closed). Reset and reconnect. backoff.reset(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (Exception e) { backoff.recordFailure(); - log.warn("Stream disconnected (attempt {}): {}", backoff, e.getMessage()); + log.warn("Stream disconnected: {}", e.getMessage()); } } log.info("Work stream loop exited"); @@ -126,7 +121,6 @@ public void onCompleted() { requestObserver.set(observer); - // 1. Register send( WorkRequest.newBuilder() .setRegister( @@ -136,10 +130,7 @@ public void onCompleted() { .addAllCapabilities(capabilities)) .build()); - // 2. Initial pull pull(maxConcurrency); - - // 3. Wait until stream ends latch.await(); } finally { requestObserver.set(null); @@ -160,59 +151,40 @@ private void handleResponse(WorkResponse response) { reg.getDefaultLease().getSeconds(), reg.getMaxInflight()); } - case WORK_ITEM -> { - var item = response.getWorkItem(); inflight.incrementAndGet(); - workerPool.submit(() -> processWorkItem(item)); - } - - case RESULT_ACCEPTED -> { - log.debug("Result accepted: {}", response.getResultAccepted().getWorkId()); + workerPool.submit(() -> processWorkItem(response.getWorkItem())); } - + case RESULT_ACCEPTED -> + log.debug("Result accepted: {}", response.getResultAccepted().getWorkId()); case LEASE_EXPIRING -> { - var warning = response.getLeaseExpiring(); - log.debug( - "Lease expiring for {}, {}s remaining", - warning.getWorkId(), - warning.getRemaining().getSeconds()); - // Auto-extend by default. Integrations can override via ExileClient.Builder. + var w = response.getLeaseExpiring(); + log.debug("Lease expiring for {}, {}s remaining", w.getWorkId(), + w.getRemaining().getSeconds()); send( WorkRequest.newBuilder() .setExtendLease( ExtendLease.newBuilder() - .setWorkId(warning.getWorkId()) + .setWorkId(w.getWorkId()) .setExtension( - com.google.protobuf.Duration.newBuilder().setSeconds(300).build())) - .build()); - } - - case LEASE_EXTENDED -> { - log.debug("Lease extended for {}", response.getLeaseExtended().getWorkId()); - } - - case NACK_ACCEPTED -> { - log.debug("Nack accepted: {}", response.getNackAccepted().getWorkId()); - } - - case HEARTBEAT -> { - send( - WorkRequest.newBuilder() - .setHeartbeat( - Heartbeat.newBuilder() - .setClientTime( - com.google.protobuf.Timestamp.newBuilder() - .setSeconds(Instant.now().getEpochSecond()))) + 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(); - log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); + log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), + err.getMessage()); } - - default -> log.debug("Unknown response type: {}", response.getPayloadCase()); + default -> {} } } @@ -238,47 +210,143 @@ private void processWorkItem(WorkItem item) { .build()); } finally { inflight.decrementAndGet(); - pull(1); // Replenish one slot. + pull(1); } } + // ---- Job dispatch: call handler with plain Java types, convert result to proto ---- + private Result.Builder dispatchJob(WorkItem item) throws Exception { var b = Result.newBuilder().setWorkId(item.getWorkId()).setFinal(true); + switch (item.getTaskCase()) { - case LIST_POOLS -> b.setListPools(jobHandler.listPools(item.getListPools())); - case GET_POOL_STATUS -> b.setGetPoolStatus(jobHandler.getPoolStatus(item.getGetPoolStatus())); - case GET_POOL_RECORDS -> - b.setGetPoolRecords(jobHandler.getPoolRecords(item.getGetPoolRecords())); - case SEARCH_RECORDS -> b.setSearchRecords(jobHandler.searchRecords(item.getSearchRecords())); - case GET_RECORD_FIELDS -> - b.setGetRecordFields(jobHandler.getRecordFields(item.getGetRecordFields())); - case SET_RECORD_FIELDS -> - b.setSetRecordFields(jobHandler.setRecordFields(item.getSetRecordFields())); - case CREATE_PAYMENT -> b.setCreatePayment(jobHandler.createPayment(item.getCreatePayment())); - case POP_ACCOUNT -> b.setPopAccount(jobHandler.popAccount(item.getPopAccount())); - case EXECUTE_LOGIC -> b.setExecuteLogic(jobHandler.executeLogic(item.getExecuteLogic())); - case INFO -> b.setInfo(jobHandler.info(item.getInfo())); - case SHUTDOWN -> b.setShutdown(jobHandler.shutdown(item.getShutdown())); - case LOGGING -> b.setLogging(jobHandler.logging(item.getLogging())); - case DIAGNOSTICS -> b.setDiagnostics(jobHandler.diagnostics(item.getDiagnostics())); - case LIST_TENANT_LOGS -> - b.setListTenantLogs(jobHandler.listTenantLogs(item.getListTenantLogs())); - case SET_LOG_LEVEL -> b.setSetLogLevel(jobHandler.setLogLevel(item.getSetLogLevel())); - default -> throw new UnsupportedOperationException("Unknown job type: " + 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(ProtoConverter::fromRecord).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(ProtoConverter::fromRecord).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()); } return b; } + // ---- Event dispatch: convert proto to plain Java, call handler ---- + private void dispatchEvent(WorkItem item) throws Exception { switch (item.getTaskCase()) { - case AGENT_CALL -> eventHandler.onAgentCall(item.getAgentCall()); - case TELEPHONY_RESULT -> eventHandler.onTelephonyResult(item.getTelephonyResult()); - case AGENT_RESPONSE -> eventHandler.onAgentResponse(item.getAgentResponse()); - case TRANSFER_INSTANCE -> eventHandler.onTransferInstance(item.getTransferInstance()); - case CALL_RECORDING -> eventHandler.onCallRecording(item.getCallRecording()); - case TASK -> eventHandler.onTask(item.getTask()); - default -> - throw new UnsupportedOperationException("Unknown event type: " + 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 TASK -> eventHandler.onTask(toTaskEvent(item.getTask())); + default -> throw new UnsupportedOperationException("Unknown event: " + item.getTaskCase()); } } @@ -302,9 +370,7 @@ private void send(WorkRequest request) { @Override public void close() { running.set(false); - if (streamThread != null) { - streamThread.interrupt(); - } + if (streamThread != null) streamThread.interrupt(); var observer = requestObserver.getAndSet(null); if (observer != null) { try { @@ -313,8 +379,6 @@ public void close() { } } workerPool.close(); - if (channel != null) { - ChannelFactory.shutdown(channel); - } + if (channel != null) ChannelFactory.shutdown(channel); } } 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/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/Record.java b/core/src/main/java/com/tcn/exile/model/Record.java new file mode 100644 index 0000000..88f1d83 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Record.java @@ -0,0 +1,5 @@ +package com.tcn.exile.model; + +import java.util.Map; + +public record Record(String poolId, String recordId, Map payload) {} 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..fd6357e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java @@ -0,0 +1,34 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +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/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java index 636ab38..8711a94 100644 --- a/core/src/main/java/com/tcn/exile/service/AgentService.java +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -1,71 +1,124 @@ 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; import tcnapi.exile.agent.v3.*; -/** Thin wrapper around the v3 AgentService gRPC stub. */ +/** Agent management operations. No proto types in the public API. */ public final class AgentService { private final AgentServiceGrpc.AgentServiceBlockingStub stub; - public AgentService(ManagedChannel channel) { + AgentService(ManagedChannel channel) { this.stub = AgentServiceGrpc.newBlockingStub(channel); } - public GetAgentResponse getAgent(GetAgentRequest request) { - return stub.getAgent(request); - } - - public ListAgentsResponse listAgents(ListAgentsRequest request) { - return stub.listAgents(request); + public Agent getAgentByPartnerId(String partnerAgentId) { + var resp = stub.getAgent( + GetAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + return toAgent(resp.getAgent()); } - public UpsertAgentResponse upsertAgent(UpsertAgentRequest request) { - return stub.upsertAgent(request); + public Agent getAgentByUserId(String userId) { + var resp = stub.getAgent(GetAgentRequest.newBuilder().setUserId(userId).build()); + return toAgent(resp.getAgent()); } - public SetAgentCredentialsResponse setAgentCredentials(SetAgentCredentialsRequest request) { - return stub.setAgentCredentials(request); + public Page listAgents(Boolean loggedIn, AgentState state, + boolean includeRecordingStatus, String pageToken, int pageSize) { + var req = 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 GetAgentStatusResponse getAgentStatus(GetAgentStatusRequest request) { - return stub.getAgentStatus(request); + public Agent upsertAgent(String partnerAgentId, String username, String firstName, + String lastName) { + var resp = stub.upsertAgent( + UpsertAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setUsername(username) + .setFirstName(firstName) + .setLastName(lastName) + .build()); + return toAgent(resp.getAgent()); } - public UpdateAgentStatusResponse updateAgentStatus(UpdateAgentStatusRequest request) { - return stub.updateAgentStatus(request); + public void setAgentCredentials(String partnerAgentId, String password) { + stub.setAgentCredentials( + SetAgentCredentialsRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setPassword(password) + .build()); } - public MuteAgentResponse muteAgent(MuteAgentRequest request) { - return stub.muteAgent(request); + public void updateAgentStatus(String partnerAgentId, AgentState newState, String reason) { + stub.updateAgentStatus( + UpdateAgentStatusRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setNewState(fromAgentState(newState)) + .setReason(reason != null ? reason : "") + .build()); } - public UnmuteAgentResponse unmuteAgent(UnmuteAgentRequest request) { - return stub.unmuteAgent(request); + public void muteAgent(String partnerAgentId) { + stub.muteAgent(MuteAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); } - public AddAgentCallResponseResponse addAgentCallResponse(AddAgentCallResponseRequest request) { - return stub.addAgentCallResponse(request); + public void unmuteAgent(String partnerAgentId) { + stub.unmuteAgent(UnmuteAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); } - public ListHuntGroupPauseCodesResponse listHuntGroupPauseCodes( - ListHuntGroupPauseCodesRequest request) { - return stub.listHuntGroupPauseCodes(request); + public void addAgentCallResponse(String partnerAgentId, long callSid, CallType callType, + String sessionId, String key, String value) { + stub.addAgentCallResponse( + AddAgentCallResponseRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setCallSid(callSid) + .setCallType( + tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCurrentSessionId(sessionId) + .setKey(key) + .setValue(value) + .build()); } - public ListSkillsResponse listSkills(ListSkillsRequest request) { - return stub.listSkills(request); + public List listSkills() { + var resp = stub.listSkills(ListSkillsRequest.getDefaultInstance()); + return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); } - public ListAgentSkillsResponse listAgentSkills(ListAgentSkillsRequest request) { - return stub.listAgentSkills(request); + public List listAgentSkills(String partnerAgentId) { + var resp = stub.listAgentSkills( + ListAgentSkillsRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); } - public AssignAgentSkillResponse assignAgentSkill(AssignAgentSkillRequest request) { - return stub.assignAgentSkill(request); + public void assignAgentSkill(String partnerAgentId, String skillId, long proficiency) { + stub.assignAgentSkill( + AssignAgentSkillRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setSkillId(skillId) + .setProficiency(proficiency) + .build()); } - public UnassignAgentSkillResponse unassignAgentSkill(UnassignAgentSkillRequest request) { - return stub.unassignAgentSkill(request); + public void unassignAgentSkill(String partnerAgentId, String skillId) { + stub.unassignAgentSkill( + 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 index 64c9fe3..5164971 100644 --- a/core/src/main/java/com/tcn/exile/service/CallService.java +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -1,43 +1,104 @@ package com.tcn.exile.service; +import com.tcn.exile.model.CallType; import io.grpc.ManagedChannel; +import java.util.Map; import tcnapi.exile.call.v3.*; -/** Thin wrapper around the v3 CallService gRPC stub. */ +/** Call control operations. No proto types in the public API. */ public final class CallService { private final CallServiceGrpc.CallServiceBlockingStub stub; - public CallService(ManagedChannel channel) { + CallService(ManagedChannel channel) { this.stub = CallServiceGrpc.newBlockingStub(channel); } - public DialResponse dial(DialRequest request) { - return stub.dial(request); + 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 = 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 TransferResponse transfer(TransferRequest request) { - return stub.transfer(request); + public void transfer(String partnerAgentId, String kind, String action, + String destAgentId, String destPhone, Map destSkills) { + var req = TransferRequest.newBuilder().setPartnerAgentId(partnerAgentId); + req.setKind(TransferRequest.TransferKind.valueOf("TRANSFER_KIND_" + kind)); + req.setAction(TransferRequest.TransferAction.valueOf("TRANSFER_ACTION_" + action)); + if (destAgentId != null) { + req.setAgent(TransferRequest.AgentDestination.newBuilder() + .setPartnerAgentId(destAgentId)); + } else if (destPhone != null) { + req.setOutbound(TransferRequest.OutboundDestination.newBuilder() + .setPhoneNumber(destPhone)); + } else if (destSkills != null) { + req.setQueue(TransferRequest.QueueDestination.newBuilder().putAllRequiredSkills(destSkills)); + } + stub.transfer(req.build()); } - public SetHoldStateResponse setHoldState(SetHoldStateRequest request) { - return stub.setHoldState(request); + public enum HoldTarget { CALL, TRANSFER_CALLER, TRANSFER_AGENT } + public enum HoldAction { HOLD, UNHOLD } + + public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) { + stub.setHoldState( + SetHoldStateRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setTarget(SetHoldStateRequest.HoldTarget.valueOf( + "HOLD_TARGET_" + target.name())) + .setAction(SetHoldStateRequest.HoldAction.valueOf( + "HOLD_ACTION_" + action.name())) + .build()); } - public StartCallRecordingResponse startCallRecording(StartCallRecordingRequest request) { - return stub.startCallRecording(request); + public void startCallRecording(String partnerAgentId) { + stub.startCallRecording( + StartCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); } - public StopCallRecordingResponse stopCallRecording(StopCallRecordingRequest request) { - return stub.stopCallRecording(request); + public void stopCallRecording(String partnerAgentId) { + stub.stopCallRecording( + StopCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); } - public GetRecordingStatusResponse getRecordingStatus(GetRecordingStatusRequest request) { - return stub.getRecordingStatus(request); + public boolean getRecordingStatus(String partnerAgentId) { + return stub + .getRecordingStatus( + GetRecordingStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()) + .getIsRecording(); } - public ListComplianceRulesetsResponse listComplianceRulesets( - ListComplianceRulesetsRequest request) { - return stub.listComplianceRulesets(request); + public java.util.List listComplianceRulesets() { + return stub + .listComplianceRulesets(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 index ecef73e..9a6ce19 100644 --- a/core/src/main/java/com/tcn/exile/service/ConfigService.java +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -1,36 +1,62 @@ package com.tcn.exile.service; +import com.tcn.exile.internal.ProtoConverter; +import com.tcn.exile.model.Record; import io.grpc.ManagedChannel; +import java.util.Map; import tcnapi.exile.config.v3.*; -/** Thin wrapper around the v3 ConfigService gRPC stub. */ +/** Configuration and lifecycle operations. No proto types in the public API. */ public final class ConfigService { private final ConfigServiceGrpc.ConfigServiceBlockingStub stub; - public ConfigService(ManagedChannel channel) { + ConfigService(ManagedChannel channel) { this.stub = ConfigServiceGrpc.newBlockingStub(channel); } - public GetClientConfigurationResponse getClientConfiguration( - GetClientConfigurationRequest request) { - return stub.getClientConfiguration(request); + 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(GetClientConfigurationRequest.getDefaultInstance()); + return new ClientConfiguration( + resp.getOrgId(), + resp.getOrgName(), + resp.getConfigName(), + ProtoConverter.structToMap(resp.getConfigPayload())); } - public GetOrganizationInfoResponse getOrganizationInfo(GetOrganizationInfoRequest request) { - return stub.getOrganizationInfo(request); + public OrgInfo getOrganizationInfo() { + var resp = stub.getOrganizationInfo(GetOrganizationInfoRequest.getDefaultInstance()); + return new OrgInfo(resp.getOrgId(), resp.getOrgName()); } - public RotateCertificateResponse rotateCertificate(RotateCertificateRequest request) { - return stub.rotateCertificate(request); + public String rotateCertificate(String certificateHash) { + var resp = stub.rotateCertificate( + RotateCertificateRequest.newBuilder().setCertificateHash(certificateHash).build()); + return resp.getEncodedCertificate(); } - public LogResponse log(LogRequest request) { - return stub.log(request); + public void log(String payload) { + stub.log(LogRequest.newBuilder().setPayload(payload).build()); } - public AddRecordToJourneyBufferResponse addRecordToJourneyBuffer( - AddRecordToJourneyBufferRequest request) { - return stub.addRecordToJourneyBuffer(request); + public enum JourneyBufferStatus { INSERTED, UPDATED, IGNORED, REJECTED, UNSPECIFIED } + + public JourneyBufferStatus addRecordToJourneyBuffer(Record record) { + var resp = stub.addRecordToJourneyBuffer( + 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 index 2592497..e63b397 100644 --- a/core/src/main/java/com/tcn/exile/service/RecordingService.java +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -1,31 +1,87 @@ 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; import tcnapi.exile.recording.v3.*; -/** Thin wrapper around the v3 RecordingService gRPC stub. */ +/** Voice recording search and retrieval. No proto types in the public API. */ public final class RecordingService { private final RecordingServiceGrpc.RecordingServiceBlockingStub stub; - public RecordingService(ManagedChannel channel) { + RecordingService(ManagedChannel channel) { this.stub = RecordingServiceGrpc.newBlockingStub(channel); } - public SearchVoiceRecordingsResponse searchVoiceRecordings( - SearchVoiceRecordingsRequest request) { - return stub.searchVoiceRecordings(request); + 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 = 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 GetDownloadLinkResponse getDownloadLink(GetDownloadLinkRequest request) { - return stub.getDownloadLink(request); + public DownloadLinks getDownloadLink(String recordingId, Duration startOffset, + Duration endOffset) { + var req = 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 ListSearchableFieldsResponse listSearchableFields(ListSearchableFieldsRequest request) { - return stub.listSearchableFields(request); + public List listSearchableFields() { + return stub + .listSearchableFields(ListSearchableFieldsRequest.getDefaultInstance()) + .getFieldsList(); } - public CreateLabelResponse createLabel(CreateLabelRequest request) { - return stub.createLabel(request); + public void createLabel(long callSid, CallType callType, String key, String value) { + stub.createLabel( + CreateLabelRequest.newBuilder() + .setCallSid(callSid) + .setCallType( + tcnapi.exile.types.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 index 511b637..9694d3c 100644 --- a/core/src/main/java/com/tcn/exile/service/ScrubListService.java +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -1,30 +1,66 @@ package com.tcn.exile.service; import io.grpc.ManagedChannel; +import java.time.Instant; +import java.util.List; import tcnapi.exile.scrublist.v3.*; -/** Thin wrapper around the v3 ScrubListService gRPC stub. */ +/** Scrub list management. No proto types in the public API. */ public final class ScrubListService { private final ScrubListServiceGrpc.ScrubListServiceBlockingStub stub; - public ScrubListService(ManagedChannel channel) { + ScrubListService(ManagedChannel channel) { this.stub = ScrubListServiceGrpc.newBlockingStub(channel); } - public ListScrubListsResponse listScrubLists(ListScrubListsRequest request) { - return stub.listScrubLists(request); + 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(ListScrubListsRequest.getDefaultInstance()).getScrubListsList() + .stream() + .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), + sl.getContentType().name())) + .toList(); } - public AddEntriesResponse addEntries(AddEntriesRequest request) { - return stub.addEntries(request); + public void addEntries(String scrubListId, List entries, + String defaultCountryCode) { + var req = AddEntriesRequest.newBuilder().setScrubListId(scrubListId); + if (defaultCountryCode != null) req.setDefaultCountryCode(defaultCountryCode); + for (var e : entries) { + var eb = tcnapi.exile.types.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 UpdateEntryResponse updateEntry(UpdateEntryRequest request) { - return stub.updateEntry(request); + public void updateEntry(String scrubListId, ScrubListEntry entry) { + var eb = tcnapi.exile.types.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( + UpdateEntryRequest.newBuilder().setScrubListId(scrubListId).setEntry(eb).build()); } - public RemoveEntriesResponse removeEntries(RemoveEntriesRequest request) { - return stub.removeEntries(request); + public void removeEntries(String scrubListId, List entries) { + stub.removeEntries( + 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..9e1298a --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java @@ -0,0 +1,24 @@ +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) {} + + public static Services create(ManagedChannel channel) { + return new Services( + new AgentService(channel), + new CallService(channel), + new RecordingService(channel), + new ScrubListService(channel), + new ConfigService(channel)); + } +} From b64e0a12b268be450bd19f6d0c7a6cb891726342 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:37:07 -0600 Subject: [PATCH 03/50] Add StreamStatus to expose work stream health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New public type StreamStatus with Phase enum tracking the stream lifecycle: IDLE → CONNECTING → REGISTERING → ACTIVE → RECONNECTING → CLOSED. Includes: clientId, connectedSince, lastDisconnect, lastError, inflight count, completedTotal, failedTotal, reconnectAttempts. Accessible via ExileClient.streamStatus() — returns a snapshot. Uses isHealthy() convenience method for simple health checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/tcn/exile/ExileClient.java | 5 + .../main/java/com/tcn/exile/StreamStatus.java | 49 +++++++++ .../tcn/exile/internal/WorkStreamClient.java | 100 ++++++++++++++---- 3 files changed, 132 insertions(+), 22 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/StreamStatus.java diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index c80ce9e..da16f64 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -87,6 +87,11 @@ public void start() { workStream.start(); } + /** Returns a snapshot of the work stream's current state. */ + public StreamStatus streamStatus() { + return workStream.status(); + } + public ExileConfig config() { return config; } 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/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index e5db12d..621da15 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -3,6 +3,8 @@ import static com.tcn.exile.internal.ProtoConverter.*; 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.*; @@ -16,8 +18,8 @@ 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 java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tcnapi.exile.worker.v3.*; @@ -46,6 +48,16 @@ public final class WorkStreamClient implements AutoCloseable { private final AtomicInteger inflight = new AtomicInteger(0); private final ExecutorService workerPool = Executors.newVirtualThreadPerTaskExecutor(); + // 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; @@ -66,6 +78,20 @@ public WorkStreamClient( 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"); @@ -78,7 +104,11 @@ private void reconnectLoop() { var backoff = new Backoff(); while (running.get()) { try { + if (backoff.nextDelayMs() > 0) { + phase = Phase.RECONNECTING; + } backoff.sleep(); + reconnectAttempts.incrementAndGet(); runStream(); backoff.reset(); } catch (InterruptedException e) { @@ -86,13 +116,19 @@ private void reconnectLoop() { break; } catch (Exception e) { backoff.recordFailure(); + lastDisconnect = Instant.now(); + lastError = e.getMessage(); + connectedSince = null; + clientId = null; log.warn("Stream disconnected: {}", e.getMessage()); } } + phase = Phase.CLOSED; log.info("Work stream loop exited"); } private void runStream() throws InterruptedException { + phase = Phase.CONNECTING; channel = ChannelFactory.create(config); try { var stub = WorkerServiceGrpc.newStub(channel); @@ -108,12 +144,17 @@ public void onNext(WorkResponse response) { @Override public void onError(Throwable t) { + lastError = t.getMessage(); + lastDisconnect = Instant.now(); + connectedSince = null; log.warn("Stream error: {}", t.getMessage()); latch.countDown(); } @Override public void onCompleted() { + lastDisconnect = Instant.now(); + connectedSince = null; log.info("Stream completed by server"); latch.countDown(); } @@ -121,6 +162,8 @@ public void onCompleted() { requestObserver.set(observer); + // Register. + phase = Phase.REGISTERING; send( WorkRequest.newBuilder() .setRegister( @@ -130,7 +173,7 @@ public void onCompleted() { .addAllCapabilities(capabilities)) .build()); - pull(maxConcurrency); + // Wait until stream ends. latch.await(); } finally { requestObserver.set(null); @@ -144,12 +187,17 @@ private void handleResponse(WorkResponse response) { switch (response.getPayloadCase()) { case REGISTERED -> { var reg = response.getRegistered(); + clientId = reg.getClientId(); + connectedSince = Instant.now(); + phase = Phase.ACTIVE; log.info( "Registered as {} (heartbeat={}s, lease={}s, max_inflight={})", reg.getClientId(), reg.getHeartbeatInterval().getSeconds(), reg.getDefaultLease().getSeconds(), reg.getMaxInflight()); + // Initial pull now that we're registered. + pull(maxConcurrency); } case WORK_ITEM -> { inflight.incrementAndGet(); @@ -159,7 +207,9 @@ private void handleResponse(WorkResponse response) { log.debug("Result accepted: {}", response.getResultAccepted().getWorkId()); case LEASE_EXPIRING -> { var w = response.getLeaseExpiring(); - log.debug("Lease expiring for {}, {}s remaining", w.getWorkId(), + log.debug( + "Lease expiring for {}, {}s remaining", + w.getWorkId(), w.getRemaining().getSeconds()); send( WorkRequest.newBuilder() @@ -181,8 +231,9 @@ private void handleResponse(WorkResponse response) { .build()); case ERROR -> { var err = response.getError(); - log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), - err.getMessage()); + lastError = err.getCode() + ": " + err.getMessage(); + log.warn( + "Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); } default -> {} } @@ -198,7 +249,9 @@ private void processWorkItem(WorkItem item) { dispatchEvent(item); send(WorkRequest.newBuilder().setAck(Ack.newBuilder().addWorkIds(workId)).build()); } + completedTotal.incrementAndGet(); } catch (Exception e) { + failedTotal.incrementAndGet(); log.warn("Work item {} failed: {}", workId, e.getMessage()); send( WorkRequest.newBuilder() @@ -214,8 +267,6 @@ private void processWorkItem(WorkItem item) { } } - // ---- Job dispatch: call handler with plain Java types, convert result to proto ---- - private Result.Builder dispatchJob(WorkItem item) throws Exception { var b = Result.newBuilder().setWorkId(item.getWorkId()).setFinal(true); @@ -232,8 +283,8 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { } case GET_POOL_RECORDS -> { var task = item.getGetPoolRecords(); - var page = jobHandler.getPoolRecords(task.getPoolId(), task.getPageToken(), - task.getPageSize()); + var page = + jobHandler.getPoolRecords(task.getPoolId(), task.getPageToken(), task.getPageSize()); b.setGetPoolRecords( GetPoolRecordsResult.newBuilder() .addAllRecords(page.items().stream().map(ProtoConverter::fromRecord).toList()) @@ -250,8 +301,9 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { } case GET_RECORD_FIELDS -> { var task = item.getGetRecordFields(); - var fields = jobHandler.getRecordFields(task.getPoolId(), task.getRecordId(), - task.getFieldNamesList()); + var fields = + jobHandler.getRecordFields( + task.getPoolId(), task.getRecordId(), task.getFieldNamesList()); b.setGetRecordFields( GetRecordFieldsResult.newBuilder() .addAllFields(fields.stream().map(ProtoConverter::fromField).toList())); @@ -264,8 +316,9 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { } case CREATE_PAYMENT -> { var task = item.getCreatePayment(); - var paymentId = jobHandler.createPayment(task.getPoolId(), task.getRecordId(), - structToMap(task.getPaymentData())); + var paymentId = + jobHandler.createPayment( + task.getPoolId(), task.getRecordId(), structToMap(task.getPaymentData())); b.setCreatePayment( CreatePaymentResult.newBuilder().setSuccess(true).setPaymentId(paymentId)); } @@ -276,8 +329,8 @@ var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId()); } case EXECUTE_LOGIC -> { var task = item.getExecuteLogic(); - var output = jobHandler.executeLogic(task.getLogicName(), - structToMap(task.getParameters())); + var output = + jobHandler.executeLogic(task.getLogicName(), structToMap(task.getParameters())); b.setExecuteLogic(ExecuteLogicResult.newBuilder().setOutput(mapToStruct(output))); } case INFO -> { @@ -307,11 +360,15 @@ var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId()); } 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() : ""); + 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() @@ -332,8 +389,6 @@ var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId()); return b; } - // ---- Event dispatch: convert proto to plain Java, call handler ---- - private void dispatchEvent(WorkItem item) throws Exception { switch (item.getTaskCase()) { case AGENT_CALL -> eventHandler.onAgentCall(toAgentCallEvent(item.getAgentCall())); @@ -370,6 +425,7 @@ private void send(WorkRequest request) { @Override public void close() { running.set(false); + phase = Phase.CLOSED; if (streamThread != null) streamThread.interrupt(); var observer = requestObserver.getAndSet(null); if (observer != null) { From 4ef34a753e297c0b9f8bb09e33779353df0d0d8f Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:45:42 -0600 Subject: [PATCH 04/50] Add sati-config module for config lifecycle management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module that eliminates the 400-500 line ConfigChangeWatcher copy-pasted across finvi, capone, latitude, and debtnet. Components: - ConfigParser: reads the Base64-encoded JSON config file (com.tcn.exiles.sati.config.cfg) and produces an ExileConfig. No Jackson dependency — minimal built-in JSON parser. - ConfigFileWatcher: watches /workdir/config for file changes using directory-watcher. Fires Listener callbacks on create/modify/delete. Handles directory discovery and creation. - CertificateRotator: checks certificate expiration and rotates via the gate config service. Writes rotated cert back to the config file (triggering watcher reload). - ExileClientManager: single-tenant lifecycle manager. Watches config file, creates/destroys ExileClient on changes, detects org changes, schedules cert rotation. One builder replaces the entire ConfigChangeWatcher + manual bean wiring. - MultiTenantManager: multi-tenant lifecycle manager for velosidy-like deployments. Polls a tenant provider, reconciles desired vs actual tenants, creates/destroys ExileClients. Dependencies: sati-core + directory-watcher (single external dep). Co-Authored-By: Claude Opus 4.6 (1M context) --- config/build.gradle | 10 + .../tcn/exile/config/CertificateRotator.java | 115 +++++++ .../tcn/exile/config/ConfigFileWatcher.java | 167 ++++++++++ .../com/tcn/exile/config/ConfigParser.java | 153 ++++++++++ .../tcn/exile/config/ExileClientManager.java | 288 ++++++++++++++++++ .../tcn/exile/config/MultiTenantManager.java | 211 +++++++++++++ settings.gradle | 2 +- 7 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 config/build.gradle create mode 100644 config/src/main/java/com/tcn/exile/config/CertificateRotator.java create mode 100644 config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java create mode 100644 config/src/main/java/com/tcn/exile/config/ConfigParser.java create mode 100644 config/src/main/java/com/tcn/exile/config/ExileClientManager.java create mode 100644 config/src/main/java/com/tcn/exile/config/MultiTenantManager.java diff --git a/config/build.gradle b/config/build.gradle new file mode 100644 index 0000000..374296f --- /dev/null +++ b/config/build.gradle @@ -0,0 +1,10 @@ +plugins { + 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..d6fff66 --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java @@ -0,0 +1,115 @@ +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..e2f904d --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java @@ -0,0 +1,167 @@ +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.Optional; +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..fddc44d --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java @@ -0,0 +1,153 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileConfig; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.io.Reader; +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); + + if (endpoint != null && !endpoint.isEmpty()) { + var parts = endpoint.split(":"); + builder.apiHostname(parts[0]); + if (parts.length > 1) { + builder.apiPort(Integer.parseInt(parts[1])); + } + } + + 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..562e277 --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -0,0 +1,288 @@ +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.EventHandler; +import com.tcn.exile.handler.JobHandler; +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 java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the lifecycle of a single-tenant {@link ExileClient} driven by a config file. + * + *

Replaces the 400-500 line ConfigChangeWatcher that was copy-pasted across finvi, capone, + * latitude, and debtnet. Handles: + * + *

    + *
  • Config file watching and parsing + *
  • ExileClient creation and destruction on config changes + *
  • Org change detection (destroys old client, creates new one) + *
  • Periodic certificate rotation + *
  • Graceful shutdown + *
+ * + *

Usage: + * + *

{@code
+ * var manager = ExileClientManager.builder()
+ *     .clientName("sati-finvi")
+ *     .clientVersion("3.0.0")
+ *     .maxConcurrency(5)
+ *     .jobHandler(new FinviJobHandler(dataSource))
+ *     .eventHandler(new FinviEventHandler(dataSource))
+ *     .onConfigChange(config -> reinitializeDataSource(config))
+ *     .build();
+ *
+ * manager.start();
+ *
+ * // Access the active client.
+ * var agents = manager.client().agents().listAgents(...);
+ *
+ * // Check health.
+ * var status = manager.client().streamStatus();
+ *
+ * // Shut down.
+ * manager.stop();
+ * }
+ */ +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 JobHandler jobHandler; + private final EventHandler eventHandler; + private final Consumer onConfigChange; + 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.jobHandler = builder.jobHandler; + this.eventHandler = builder.eventHandler; + this.onConfigChange = builder.onConfigChange; + 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(); + + // Schedule certificate rotation. + 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={})", clientName); + } + + /** 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; + } + + /** Returns the config file watcher (for writing rotated certs). */ + ConfigFileWatcher configWatcher() { + return watcher; + } + + /** Stop the manager, close the client, and stop watching. */ + 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) { + // If org changed, destroy the old client first. + var newOrg = config.org(); + if (activeOrg != null && !activeOrg.equals(newOrg)) { + log.info("Org changed from {} to {}, destroying old client", activeOrg, newOrg); + destroyClient(); + } + + // Notify integration of config change (e.g., reinit datasource). + if (onConfigChange != null) { + try { + onConfigChange.accept(config); + } catch (Exception e) { + log.error("onConfigChange callback failed: {}", e.getMessage(), e); + return; + } + } + + // Destroy existing client if any (handles reconnect with new certs). + destroyClient(); + + try { + activeClient = + ExileClient.builder() + .config(config) + .clientName(clientName) + .clientVersion(clientVersion) + .maxConcurrency(maxConcurrency) + .jobHandler(jobHandler) + .eventHandler(eventHandler) + .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 = 5; + private JobHandler jobHandler = new JobHandler() {}; + private EventHandler eventHandler = new EventHandler() {}; + private Consumer onConfigChange; + private List watchDirs; + private int certRotationHours = 1; + + private Builder() {} + + /** Human-readable client name for diagnostics. */ + 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; + } + + public Builder jobHandler(JobHandler jobHandler) { + this.jobHandler = Objects.requireNonNull(jobHandler); + return this; + } + + public Builder eventHandler(EventHandler eventHandler) { + this.eventHandler = Objects.requireNonNull(eventHandler); + return this; + } + + /** + * Callback invoked when config changes, before the ExileClient is (re)created. Use this to + * reinitialize integration-specific resources like database connections. + * + *

The callback receives the new {@link ExileConfig}. If it throws, the client will not be + * created. + */ + public Builder onConfigChange(Consumer onConfigChange) { + this.onConfigChange = onConfigChange; + return this; + } + + /** + * Override the default config directory paths. Defaults to {@code /workdir/config} and + * {@code workdir/config}. + */ + public Builder watchDirs(List watchDirs) { + this.watchDirs = watchDirs; + return this; + } + + /** How often to check certificate expiration (hours). Default: 1. */ + public Builder certRotationHours(int hours) { + this.certRotationHours = hours; + return this; + } + + public ExileClientManager build() { + 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..c29c26d --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java @@ -0,0 +1,211 @@ +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/settings.gradle b/settings.gradle index 8c42a77..1db25d6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,3 @@ rootProject.name="sati" -include('core', 'logback-ext') +include('core', 'config', 'logback-ext') From edabb7eea09bce016f54925b079064040cdf79d0 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:48:11 -0600 Subject: [PATCH 05/50] Add java-library plugin to core and config modules The api() dependency configuration requires the java-library plugin. Both modules were missing it, causing build failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/build.gradle | 1 + core/build.gradle | 1 + 2 files changed, 2 insertions(+) diff --git a/config/build.gradle b/config/build.gradle index 374296f..72e62ce 100644 --- a/config/build.gradle +++ b/config/build.gradle @@ -1,4 +1,5 @@ plugins { + id("java-library") id("maven-publish") } diff --git a/core/build.gradle b/core/build.gradle index 453f1ad..78dbcce 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,4 +1,5 @@ plugins { + id("java-library") id("com.github.johnrengelman.shadow") id("maven-publish") id("jacoco") From fa3b7a2834a145ce0386a598ed27d122bbc31530 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:02:26 -0600 Subject: [PATCH 06/50] Integrate buf gradle plugin for local proto generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace pre-built buf.build Maven artifacts with local code generation using the build.buf gradle plugin. Proto stubs are now generated from source at build time instead of depending on published artifacts. Changes: - Add build.buf plugin (v0.11.0) to core module - Add buf.yaml, buf.gen.yaml, buf.lock for code generation config - Copy v3 proto files into core/proto/ (source of truth) - Pin protoc java plugin to v28.3 and grpc java to v1.68.1 - Add java_multiple_files=true to all proto files (generates top-level classes instead of nested inner classes) - Bump protobuf-java to 4.28.3 (matches remote plugin output) - Move grpc-netty-shaded from runtimeOnly to implementation (ChannelFactory needs compile-time access) - Rename model.Record to model.DataRecord (avoids collision with java.lang.Record) - Fix WorkItem.task oneof field name collision (task→exile_task) - Remove exileapi Maven version pins from gradle.properties - Apply spotless and buf format fixes The build now generates stubs from proto source, so sati can build independently of whether exileapi v3 is published to buf.build BSR. Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 6 +- .../tcn/exile/config/CertificateRotator.java | 6 +- .../tcn/exile/config/ConfigFileWatcher.java | 27 +- .../com/tcn/exile/config/ConfigParser.java | 35 +- .../tcn/exile/config/ExileClientManager.java | 16 +- .../tcn/exile/config/MultiTenantManager.java | 12 +- core/buf.gen.yaml | 7 + core/buf.lock | 6 + core/buf.yaml | 13 + core/build.gradle | 35 +- .../proto/tcnapi/exile/agent/v3/service.proto | 256 ++++++++++ core/proto/tcnapi/exile/call/v3/service.proto | 207 ++++++++ .../tcnapi/exile/config/v3/service.proto | 98 ++++ .../tcnapi/exile/recording/v3/service.proto | 103 ++++ .../tcnapi/exile/scrublist/v3/service.proto | 68 +++ core/proto/tcnapi/exile/types/v3/types.proto | 454 +++++++++++++++++ .../tcnapi/exile/worker/v3/service.proto | 465 ++++++++++++++++++ .../com/tcn/exile/handler/JobHandler.java | 10 +- .../tcn/exile/internal/ProtoConverter.java | 17 +- .../tcn/exile/internal/WorkStreamClient.java | 23 +- .../java/com/tcn/exile/model/DataRecord.java | 5 + .../main/java/com/tcn/exile/model/Record.java | 5 - .../model/event/TransferInstanceEvent.java | 1 - .../com/tcn/exile/service/AgentService.java | 55 ++- .../com/tcn/exile/service/CallService.java | 54 +- .../com/tcn/exile/service/ConfigService.java | 26 +- .../tcn/exile/service/RecordingService.java | 49 +- .../tcn/exile/service/ScrubListService.java | 20 +- gradle.properties | 4 +- 29 files changed, 1910 insertions(+), 173 deletions(-) create mode 100644 core/buf.gen.yaml create mode 100644 core/buf.lock create mode 100644 core/buf.yaml create mode 100644 core/proto/tcnapi/exile/agent/v3/service.proto create mode 100644 core/proto/tcnapi/exile/call/v3/service.proto create mode 100644 core/proto/tcnapi/exile/config/v3/service.proto create mode 100644 core/proto/tcnapi/exile/recording/v3/service.proto create mode 100644 core/proto/tcnapi/exile/scrublist/v3/service.proto create mode 100644 core/proto/tcnapi/exile/types/v3/types.proto create mode 100644 core/proto/tcnapi/exile/worker/v3/service.proto create mode 100644 core/src/main/java/com/tcn/exile/model/DataRecord.java delete mode 100644 core/src/main/java/com/tcn/exile/model/Record.java diff --git a/build.gradle b/build.gradle index 204e623..e61d077 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id("com.github.johnrengelman.shadow") version "8.1.1" apply(false) + id("build.buf") version "0.11.0" apply(false) id("maven-publish") id("com.diffplug.spotless") version "7.0.3" } @@ -15,10 +16,6 @@ allprojects { repositories { mavenCentral() - maven { - name = 'buf' - url 'https://buf.build/gen/maven' - } } java { @@ -74,6 +71,7 @@ allprojects { java { googleJavaFormat() formatAnnotations() + targetExclude("build/**") } } } diff --git a/config/src/main/java/com/tcn/exile/config/CertificateRotator.java b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java index d6fff66..d95185a 100644 --- a/config/src/main/java/com/tcn/exile/config/CertificateRotator.java +++ b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java @@ -89,8 +89,7 @@ static Instant getCertExpiration(ExileConfig config) { var cert = (X509Certificate) cf.generateCertificate( - new ByteArrayInputStream( - config.publicCert().getBytes(StandardCharsets.UTF_8))); + new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8))); return cert.getNotAfter().toInstant(); } catch (Exception e) { return null; @@ -103,8 +102,7 @@ static String getCertFingerprint(ExileConfig config) { var cert = (X509Certificate) cf.generateCertificate( - new ByteArrayInputStream( - config.publicCert().getBytes(StandardCharsets.UTF_8))); + 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); diff --git a/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java index e2f904d..08ae746 100644 --- a/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java +++ b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java @@ -6,7 +6,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,8 +65,7 @@ public void start() throws IOException { } // Find or create the config directory. - configDir = - watchDirs.stream().filter(p -> p.toFile().exists()).findFirst().orElse(null); + configDir = watchDirs.stream().filter(p -> p.toFile().exists()).findFirst().orElse(null); if (configDir == null) { var fallback = watchDirs.get(0); if (fallback.toFile().mkdirs()) { @@ -133,10 +131,12 @@ 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); - }); + ConfigParser.parse(file) + .ifPresent( + config -> { + log.info("Loaded existing config for org={}", config.org()); + listener.onConfigChanged(config); + }); } } @@ -145,12 +145,13 @@ private void handleConfigFileChange(Path path) { 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)); + 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 diff --git a/config/src/main/java/com/tcn/exile/config/ConfigParser.java b/config/src/main/java/com/tcn/exile/config/ConfigParser.java index fddc44d..69687aa 100644 --- a/config/src/main/java/com/tcn/exile/config/ConfigParser.java +++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java @@ -1,9 +1,6 @@ package com.tcn.exile.config; import com.tcn.exile.ExileConfig; -import java.io.ByteArrayInputStream; -import java.io.InputStreamReader; -import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -16,8 +13,8 @@ /** * 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}). + *

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 { @@ -63,7 +60,8 @@ public static Optional parse(byte[] raw) { return Optional.empty(); } - var builder = ExileConfig.builder().rootCert(rootCert).publicCert(publicCert).privateKey(privateKey); + var builder = + ExileConfig.builder().rootCert(rootCert).publicCert(publicCert).privateKey(privateKey); if (endpoint != null && !endpoint.isEmpty()) { var parts = endpoint.split(":"); @@ -97,8 +95,12 @@ private static Map parseJson(byte[] json) { 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++; + 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. @@ -122,14 +124,15 @@ private static Map parseJson(byte[] json) { 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); - }); + 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)); } diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java index 562e277..3befbfe 100644 --- a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -93,11 +93,13 @@ public void start() throws IOException { watcher.start(); // Schedule certificate rotation. - scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - var t = new Thread(r, "exile-cert-rotator"); - t.setDaemon(true); - return t; - }); + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "exile-cert-rotator"); + t.setDaemon(true); + return t; + }); var rotator = new CertificateRotator(this); scheduler.scheduleAtFixedRate( () -> { @@ -267,8 +269,8 @@ public Builder onConfigChange(Consumer onConfigChange) { } /** - * Override the default config directory paths. Defaults to {@code /workdir/config} and - * {@code workdir/config}. + * Override the default config directory paths. Defaults to {@code /workdir/config} and {@code + * workdir/config}. */ public Builder watchDirs(List watchDirs) { this.watchDirs = watchDirs; diff --git a/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java index c29c26d..08962ab 100644 --- a/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java +++ b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java @@ -66,11 +66,13 @@ private MultiTenantManager(Builder builder) { /** 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 = + 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); } diff --git a/core/buf.gen.yaml b/core/buf.gen.yaml new file mode 100644 index 0000000..b42ac6a --- /dev/null +++ b/core/buf.gen.yaml @@ -0,0 +1,7 @@ +version: v2 +clean: true +plugins: + - remote: buf.build/protocolbuffers/java:v28.3 + out: java + - remote: buf.build/grpc/java:v1.68.1 + out: java diff --git a/core/buf.lock b/core/buf.lock new file mode 100644 index 0000000..ab5dd97 --- /dev/null +++ b/core/buf.lock @@ -0,0 +1,6 @@ +# Generated by buf. DO NOT EDIT. +version: v2 +deps: + - name: buf.build/googleapis/googleapis + commit: 536964a08a534d51b8f30f2d6751f1f9 + digest: b5:3e05d27e797b00c345fadd3c15cf0e16c4cc693036a55059721e66d6ce22a96264a4897658c9243bb0874fa9ca96e437589eb512189d2754604a626c632f6030 diff --git a/core/buf.yaml b/core/buf.yaml new file mode 100644 index 0000000..fbd9764 --- /dev/null +++ b/core/buf.yaml @@ -0,0 +1,13 @@ +version: v2 +modules: + - path: proto +deps: + - buf.build/googleapis/googleapis +lint: + use: + - STANDARD + except: + - RPC_REQUEST_STANDARD_NAME + - RPC_RESPONSE_STANDARD_NAME + - ENUM_VALUE_PREFIX + - ENUM_ZERO_VALUE_SUFFIX diff --git a/core/build.gradle b/core/build.gradle index 78dbcce..d2cea0b 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,23 +1,44 @@ plugins { id("java-library") + id("build.buf") id("com.github.johnrengelman.shadow") id("maven-publish") id("jacoco") } -dependencies { - // exileapi v3 generated stubs - api("build.buf.gen:tcn_exileapi_grpc_java:${exileapiGrpcVersion}") - api("build.buf.gen:tcn_exileapi_protocolbuffers_java:${exileapiProtobufVersion}") +// Generate Java + gRPC stubs from exileapi protos via buf. +buf { + configFileLocation = file("buf.yaml") + generate { + templateFileLocation = file("buf.gen.yaml") + includeImports = true + } +} +// Wire buf generation into the compile lifecycle. +tasks.named("compileJava").configure { dependsOn("bufGenerate") } + +// Add generated sources to the main source set. +sourceSets { + main { + java { + srcDir("${buildDir}/bufbuild/generated/java") + } + } +} + +dependencies { // gRPC — client only api("io.grpc:grpc-api:${grpcVersion}") api("io.grpc:grpc-protobuf:${grpcVersion}") api("io.grpc:grpc-stub:${grpcVersion}") - runtimeOnly("io.grpc:grpc-netty-shaded:${grpcVersion}") + implementation("io.grpc:grpc-netty-shaded:${grpcVersion}") + + // protobuf runtime + api("com.google.protobuf:protobuf-java:${protobufVersion}") - // protobuf - implementation("com.google.protobuf:protobuf-java:${protobufVersion}") + // javax.annotation for @Generated in gRPC stubs + compileOnly("org.apache.tomcat:annotations-api:6.0.53") } jacoco { diff --git a/core/proto/tcnapi/exile/agent/v3/service.proto b/core/proto/tcnapi/exile/agent/v3/service.proto new file mode 100644 index 0000000..a7bdca3 --- /dev/null +++ b/core/proto/tcnapi/exile/agent/v3/service.proto @@ -0,0 +1,256 @@ +// Agent management service. +// +// Extracted from gate/v2 GateService. Handles agent CRUD, state +// management, muting, skills, and pause codes. +// +// Changes from v2: +// - GetAgentById and GetAgentByPartnerId merged into GetAgent with +// a oneof identifier (consistent resource lookup). +// - UpsertAgent no longer carries plaintext password. Use +// SetAgentCredentials for password changes. +// - ListAgents supports pagination (page_token) in addition to streaming. +// - ListHuntGroupPauseCodes moved here from GateService (was orphaned). +// - Skill management co-located with agent (assign/unassign are +// agent-scoped operations). + +syntax = "proto3"; + +package tcnapi.exile.agent.v3; + +import "google/api/annotations.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/agent/v3;agentv3"; +option java_multiple_files = true; + +service AgentService { + // Get a single agent by user_id or partner_agent_id. + rpc GetAgent(GetAgentRequest) returns (GetAgentResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}"}; + } + + // List agents with optional filters. Supports both streaming and + // paginated responses. + rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents"}; + } + + // Create or update an agent (excluding credentials). + rpc UpsertAgent(UpsertAgentRequest) returns (UpsertAgentResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}" + body: "*" + }; + } + + // Set agent credentials separately from profile data. + // Keeps passwords out of general CRUD payloads. + rpc SetAgentCredentials(SetAgentCredentialsRequest) returns (SetAgentCredentialsResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/credentials" + body: "*" + }; + } + + // Update agent state (ready, paused, wrapup, etc.). + rpc UpdateAgentStatus(UpdateAgentStatusRequest) returns (UpdateAgentStatusResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/status" + body: "*" + }; + } + + // Get current agent state, session, and connected party. + rpc GetAgentStatus(GetAgentStatusRequest) returns (GetAgentStatusResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/status"}; + } + + rpc MuteAgent(MuteAgentRequest) returns (MuteAgentResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/mute" + body: "*" + }; + } + + rpc UnmuteAgent(UnmuteAgentRequest) returns (UnmuteAgentResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/unmute" + body: "*" + }; + } + + // Record an agent's response to a call (key/value pair). + rpc AddAgentCallResponse(AddAgentCallResponseRequest) returns (AddAgentCallResponseResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/call_responses" + body: "*" + }; + } + + // List pause codes available to an agent's hunt group. + rpc ListHuntGroupPauseCodes(ListHuntGroupPauseCodesRequest) returns (ListHuntGroupPauseCodesResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/pause_codes"}; + } + + // --- Skill management (agent-scoped) --- + + // List all skills defined in the system. + rpc ListSkills(ListSkillsRequest) returns (ListSkillsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/skills"}; + } + + // List skills assigned to a specific agent. + rpc ListAgentSkills(ListAgentSkillsRequest) returns (ListAgentSkillsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills"}; + } + + rpc AssignAgentSkill(AssignAgentSkillRequest) returns (AssignAgentSkillResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills" + body: "*" + }; + } + + rpc UnassignAgentSkill(UnassignAgentSkillRequest) returns (UnassignAgentSkillResponse) { + option (google.api.http) = {delete: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills/{skill_id}"}; + } +} + +// --------------------------------------------------------------------------- +// Request / Response messages +// --------------------------------------------------------------------------- + +message GetAgentRequest { + // Look up by either identifier. + oneof identifier { + string partner_agent_id = 1; + string user_id = 2; + } +} + +message GetAgentResponse { + types.v3.Agent agent = 1; +} + +message ListAgentsRequest { + // Optional filters. + optional bool logged_in = 1; + optional types.v3.AgentState state = 2; + bool include_recording_status = 3; + // Pagination. If empty, returns first page. + string page_token = 4; + int32 page_size = 5; +} + +message ListAgentsResponse { + repeated types.v3.Agent agents = 1; + string next_page_token = 2; +} + +message UpsertAgentRequest { + string partner_agent_id = 1; + string username = 2; + string first_name = 3; + string last_name = 4; +} + +message UpsertAgentResponse { + types.v3.Agent agent = 1; +} + +// Separate RPC for credentials. Keeps passwords out of CRUD payloads +// and audit logs. +message SetAgentCredentialsRequest { + string partner_agent_id = 1; + string password = 2; +} + +message SetAgentCredentialsResponse {} + +message UpdateAgentStatusRequest { + string partner_agent_id = 1; + types.v3.AgentState new_state = 2; + string reason = 3; +} + +message UpdateAgentStatusResponse {} + +message GetAgentStatusRequest { + string partner_agent_id = 1; +} + +message GetAgentStatusResponse { + string partner_agent_id = 1; + types.v3.AgentState agent_state = 2; + string current_session_id = 3; + optional types.v3.Agent.ConnectedParty connected_party = 4; + bool is_muted = 5; + bool is_recording = 6; +} + +message MuteAgentRequest { + string partner_agent_id = 1; +} + +message MuteAgentResponse {} + +message UnmuteAgentRequest { + string partner_agent_id = 1; +} + +message UnmuteAgentResponse {} + +message AddAgentCallResponseRequest { + string partner_agent_id = 1; + int64 call_sid = 2; + types.v3.CallType call_type = 3; + string current_session_id = 4; + string key = 5; + string value = 6; +} + +message AddAgentCallResponseResponse {} + +message ListHuntGroupPauseCodesRequest { + string partner_agent_id = 1; +} + +message ListHuntGroupPauseCodesResponse { + string name = 1; + string description = 2; + repeated PauseCode pause_codes = 3; + + message PauseCode { + string code = 1; + string description = 2; + } +} + +message ListSkillsRequest {} + +message ListSkillsResponse { + repeated types.v3.Skill skills = 1; +} + +message ListAgentSkillsRequest { + string partner_agent_id = 1; +} + +message ListAgentSkillsResponse { + repeated types.v3.Skill skills = 1; +} + +message AssignAgentSkillRequest { + string partner_agent_id = 1; + string skill_id = 2; + int64 proficiency = 3; +} + +message AssignAgentSkillResponse {} + +message UnassignAgentSkillRequest { + string partner_agent_id = 1; + string skill_id = 2; +} + +message UnassignAgentSkillResponse {} diff --git a/core/proto/tcnapi/exile/call/v3/service.proto b/core/proto/tcnapi/exile/call/v3/service.proto new file mode 100644 index 0000000..67561dd --- /dev/null +++ b/core/proto/tcnapi/exile/call/v3/service.proto @@ -0,0 +1,207 @@ +// Call control service. +// +// Extracted from gate/v2 GateService. Handles dialing, transfers, +// hold operations, recording control, and compliance. +// +// Changes from v2: +// - Transfer uses POST (was GET with body in v2 — HTTP violation). +// - Hold operations use a single RPC with action enum instead of +// 6 separate RPCs (PutCallOnSimpleHold, TakeCallOffSimpleHold, +// HoldTransferMember{Caller,Agent}, Unhold...). +// - DialResponse no longer has deprecated caller_sid. +// - NCL ruleset listing co-located here (compliance is call-scoped). + +syntax = "proto3"; + +package tcnapi.exile.call.v3; + +import "google/api/annotations.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/call/v3;callv3"; +option java_multiple_files = true; + +service CallService { + // Initiate an outbound call. + rpc Dial(DialRequest) returns (DialResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/dial" + body: "*" + }; + } + + // Transfer a call. Replaces the GET-with-body Transfer in v2. + rpc Transfer(TransferRequest) returns (TransferResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/transfer" + body: "*" + }; + } + + // Unified hold/unhold. Replaces 6 separate RPCs in v2. + rpc SetHoldState(SetHoldStateRequest) returns (SetHoldStateResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/hold" + body: "*" + }; + } + + rpc StartCallRecording(StartCallRecordingRequest) returns (StartCallRecordingResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/start" + body: "*" + }; + } + + rpc StopCallRecording(StopCallRecordingRequest) returns (StopCallRecordingResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/stop" + body: "*" + }; + } + + rpc GetRecordingStatus(GetRecordingStatusRequest) returns (GetRecordingStatusResponse) { + option (google.api.http) = {get: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/status"}; + } + + // List available compliance ruleset names. + rpc ListComplianceRulesets(ListComplianceRulesetsRequest) returns (ListComplianceRulesetsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/call/v3/compliance/rulesets"}; + } +} + +// --------------------------------------------------------------------------- +// Dial +// --------------------------------------------------------------------------- + +message DialRequest { + string partner_agent_id = 1; + string phone_number = 2; + optional string caller_id = 3; + optional string pool_id = 4; + optional string record_id = 5; + optional string ruleset_name = 6; + optional bool skip_compliance_checks = 7; + optional bool record_call = 8; +} + +message DialResponse { + string phone_number = 1; + string caller_id = 2; + int64 call_sid = 3; + types.v3.CallType call_type = 4; + string org_id = 5; + string partner_agent_id = 6; + bool attempted = 7; + string status = 8; +} + +// --------------------------------------------------------------------------- +// Transfer +// --------------------------------------------------------------------------- + +message TransferRequest { + string partner_agent_id = 1; + + oneof destination { + AgentDestination agent = 2; + OutboundDestination outbound = 3; + QueueDestination queue = 4; + } + + TransferKind kind = 5; + TransferAction action = 6; + + message AgentDestination { + string partner_agent_id = 1; + } + + message OutboundDestination { + string phone_number = 1; + optional string caller_id = 2; + } + + message QueueDestination { + map required_skills = 1; + } + + enum TransferKind { + TRANSFER_KIND_UNSPECIFIED = 0; + TRANSFER_KIND_COLD = 1; + TRANSFER_KIND_WARM = 2; + TRANSFER_KIND_CONFERENCE = 3; + } + + enum TransferAction { + TRANSFER_ACTION_UNSPECIFIED = 0; + TRANSFER_ACTION_START = 1; + TRANSFER_ACTION_APPROVE = 2; + TRANSFER_ACTION_CANCEL = 3; + } +} + +message TransferResponse {} + +// --------------------------------------------------------------------------- +// Hold (unified) +// --------------------------------------------------------------------------- + +message SetHoldStateRequest { + string partner_agent_id = 1; + + HoldTarget target = 2; + HoldAction action = 3; + + // What to hold/unhold. + enum HoldTarget { + HOLD_TARGET_UNSPECIFIED = 0; + // Simple hold on the active call. + HOLD_TARGET_CALL = 1; + // Hold the caller side of a transfer. + HOLD_TARGET_TRANSFER_CALLER = 2; + // Hold the agent side of a transfer. + HOLD_TARGET_TRANSFER_AGENT = 3; + } + + enum HoldAction { + HOLD_ACTION_UNSPECIFIED = 0; + HOLD_ACTION_HOLD = 1; + HOLD_ACTION_UNHOLD = 2; + } +} + +message SetHoldStateResponse {} + +// --------------------------------------------------------------------------- +// Recording +// --------------------------------------------------------------------------- + +message StartCallRecordingRequest { + string partner_agent_id = 1; +} + +message StartCallRecordingResponse {} + +message StopCallRecordingRequest { + string partner_agent_id = 1; +} + +message StopCallRecordingResponse {} + +message GetRecordingStatusRequest { + string partner_agent_id = 1; +} + +message GetRecordingStatusResponse { + bool is_recording = 1; +} + +// --------------------------------------------------------------------------- +// Compliance +// --------------------------------------------------------------------------- + +message ListComplianceRulesetsRequest {} + +message ListComplianceRulesetsResponse { + repeated string ruleset_names = 1; +} diff --git a/core/proto/tcnapi/exile/config/v3/service.proto b/core/proto/tcnapi/exile/config/v3/service.proto new file mode 100644 index 0000000..6642567 --- /dev/null +++ b/core/proto/tcnapi/exile/config/v3/service.proto @@ -0,0 +1,98 @@ +// Configuration and lifecycle service. +// +// Extracted from gate/v2 GateService. Handles client configuration, +// organization info, certificate rotation, logging, and journey buffer. + +syntax = "proto3"; + +package tcnapi.exile.config.v3; + +import "google/api/annotations.proto"; +import "google/protobuf/struct.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/config/v3;configv3"; +option java_multiple_files = true; + +service ConfigService { + // Get client configuration. Returns plugin config payload and org info. + // config_payload is now a Struct instead of an opaque string. + rpc GetClientConfiguration(GetClientConfigurationRequest) returns (GetClientConfigurationResponse) { + option (google.api.http) = {get: "/tcnapi/exile/config/v3/client_configuration"}; + } + + rpc GetOrganizationInfo(GetOrganizationInfoRequest) returns (GetOrganizationInfoResponse) { + option (google.api.http) = {get: "/tcnapi/exile/config/v3/organization_info"}; + } + + rpc RotateCertificate(RotateCertificateRequest) returns (RotateCertificateResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/config/v3/rotate_certificate" + body: "*" + }; + } + + // Send log messages to the platform. + rpc Log(LogRequest) returns (LogResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/config/v3/log" + body: "*" + }; + } + + // Add a record to the journey/customer context buffer. + rpc AddRecordToJourneyBuffer(AddRecordToJourneyBufferRequest) returns (AddRecordToJourneyBufferResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/config/v3/journey_buffer" + body: "*" + }; + } +} + +message GetClientConfigurationRequest {} + +message GetClientConfigurationResponse { + string org_id = 1; + string org_name = 2; + string config_name = 3; + // Typed payload instead of opaque string. Consumers deserialize + // into their plugin-specific config record. + google.protobuf.Struct config_payload = 4; +} + +message GetOrganizationInfoRequest {} + +message GetOrganizationInfoResponse { + string org_id = 1; + string org_name = 2; +} + +message RotateCertificateRequest { + string certificate_hash = 1; +} + +message RotateCertificateResponse { + string encoded_certificate = 1; +} + +message LogRequest { + string payload = 1; +} + +message LogResponse {} + +message AddRecordToJourneyBufferRequest { + types.v3.Record record = 1; +} + +message AddRecordToJourneyBufferResponse { + JourneyBufferStatus status = 1; + + enum JourneyBufferStatus { + JOURNEY_BUFFER_STATUS_UNSPECIFIED = 0; + JOURNEY_BUFFER_STATUS_INSERTED = 1; + JOURNEY_BUFFER_STATUS_UPDATED = 2; + JOURNEY_BUFFER_STATUS_IGNORED = 3; + JOURNEY_BUFFER_STATUS_REJECTED = 4; + } +} diff --git a/core/proto/tcnapi/exile/recording/v3/service.proto b/core/proto/tcnapi/exile/recording/v3/service.proto new file mode 100644 index 0000000..a5e8100 --- /dev/null +++ b/core/proto/tcnapi/exile/recording/v3/service.proto @@ -0,0 +1,103 @@ +// Voice recording search and retrieval service. +// +// Extracted from gate/v2 GateService. +// +// Changes from v2: +// - GetVoiceRecordingDownloadLink changed from GET-with-body to POST. +// - SearchVoiceRecordings supports pagination instead of streaming-only. + +syntax = "proto3"; + +package tcnapi.exile.recording.v3; + +import "google/api/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/recording/v3;recordingv3"; +option java_multiple_files = true; + +service RecordingService { + rpc SearchVoiceRecordings(SearchVoiceRecordingsRequest) returns (SearchVoiceRecordingsResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/recording/v3/search" + body: "*" + }; + } + + rpc GetDownloadLink(GetDownloadLinkRequest) returns (GetDownloadLinkResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/recording/v3/download_link" + body: "*" + }; + } + + rpc ListSearchableFields(ListSearchableFieldsRequest) returns (ListSearchableFieldsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/recording/v3/searchable_fields"}; + } + + rpc CreateLabel(CreateLabelRequest) returns (CreateLabelResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/recording/v3/labels" + body: "*" + }; + } +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +message SearchVoiceRecordingsRequest { + repeated types.v3.Filter filters = 1; + string page_token = 2; + int32 page_size = 3; +} + +message SearchVoiceRecordingsResponse { + repeated Recording recordings = 1; + string next_page_token = 2; +} + +message Recording { + string recording_id = 1; + int64 call_sid = 2; + types.v3.CallType call_type = 3; + google.protobuf.Duration start_offset = 4; + google.protobuf.Duration end_offset = 5; + google.protobuf.Timestamp start_time = 6; + google.protobuf.Duration duration = 7; + string agent_phone = 8; + string client_phone = 9; + string campaign = 10; + repeated string partner_agent_ids = 11; + string label = 12; + string value = 13; +} + +message GetDownloadLinkRequest { + string recording_id = 1; + optional google.protobuf.Duration start_offset = 2; + optional google.protobuf.Duration end_offset = 3; +} + +message GetDownloadLinkResponse { + string download_link = 1; + string playback_link = 2; +} + +message ListSearchableFieldsRequest {} + +message ListSearchableFieldsResponse { + repeated string fields = 1; +} + +message CreateLabelRequest { + int64 call_sid = 1; + types.v3.CallType call_type = 2; + string key = 3; + string value = 4; +} + +message CreateLabelResponse {} diff --git a/core/proto/tcnapi/exile/scrublist/v3/service.proto b/core/proto/tcnapi/exile/scrublist/v3/service.proto new file mode 100644 index 0000000..f49b01e --- /dev/null +++ b/core/proto/tcnapi/exile/scrublist/v3/service.proto @@ -0,0 +1,68 @@ +// Scrub list management service. +// +// Extracted from gate/v2 GateService. + +syntax = "proto3"; + +package tcnapi.exile.scrublist.v3; + +import "google/api/annotations.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/scrublist/v3;scrublistv3"; +option java_multiple_files = true; + +service ScrubListService { + rpc ListScrubLists(ListScrubListsRequest) returns (ListScrubListsResponse) { + option (google.api.http) = {get: "/tcnapi/exile/scrublist/v3/scrublists"}; + } + + rpc AddEntries(AddEntriesRequest) returns (AddEntriesResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries" + body: "*" + }; + } + + rpc UpdateEntry(UpdateEntryRequest) returns (UpdateEntryResponse) { + option (google.api.http) = { + patch: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries/{content}" + body: "*" + }; + } + + rpc RemoveEntries(RemoveEntriesRequest) returns (RemoveEntriesResponse) { + option (google.api.http) = { + post: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries:remove" + body: "*" + }; + } +} + +message ListScrubListsRequest {} + +message ListScrubListsResponse { + repeated types.v3.ScrubList scrub_lists = 1; +} + +message AddEntriesRequest { + string scrub_list_id = 1; + repeated types.v3.ScrubListEntry entries = 2; + optional string default_country_code = 3; +} + +message AddEntriesResponse {} + +message UpdateEntryRequest { + string scrub_list_id = 1; + types.v3.ScrubListEntry entry = 2; +} + +message UpdateEntryResponse {} + +message RemoveEntriesRequest { + string scrub_list_id = 1; + repeated string entries = 2; +} + +message RemoveEntriesResponse {} diff --git a/core/proto/tcnapi/exile/types/v3/types.proto b/core/proto/tcnapi/exile/types/v3/types.proto new file mode 100644 index 0000000..94b092d --- /dev/null +++ b/core/proto/tcnapi/exile/types/v3/types.proto @@ -0,0 +1,454 @@ +// Shared entity types for the Exile platform. +// +// This package fixes several design issues from core/v2 and gate/v2: +// - Parallel arrays (task_data_keys/values) replaced with repeated TaskData +// - call_type changed from string to CallType enum +// - Filter.Operator unified with SearchOption.Operator +// - Duration fields use google.protobuf.Duration +// - Skill.proficiency marked optional +// - Removed unused AgentEvent/CallerEvent (orphaned in v2 too) +// - Result enum categorized into outcome + detail + +syntax = "proto3"; + +package tcnapi.exile.types.v3; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/types/v3;typesv3"; +option java_multiple_files = true; + +// --------------------------------------------------------------------------- +// Core data types +// --------------------------------------------------------------------------- + +message Pool { + string pool_id = 1; + string description = 2; + PoolStatus status = 3; + int64 record_count = 4; + + enum PoolStatus { + POOL_STATUS_UNSPECIFIED = 0; + POOL_STATUS_READY = 1; + POOL_STATUS_NOT_READY = 2; + POOL_STATUS_BUSY = 3; + } +} + +message Record { + string pool_id = 1; + string record_id = 2; + // Structured payload instead of raw JSON string. + // Consumers that need raw JSON can use google.protobuf.Struct's JSON mapping. + google.protobuf.Struct payload = 3; +} + +message Field { + string field_name = 1; + string field_value = 2; + string pool_id = 3; + string record_id = 4; +} + +// Replaces the parallel arrays (task_data_keys + task_data_values) antipattern. +// Each entry is a self-contained key-value pair. +message TaskData { + string key = 1; + google.protobuf.Value value = 2; +} + +// Unified filter with full operator set. Replaces both core.v2.Filter +// (which only had EQUAL) and gate.v2.SearchOption (which had a partial set). +message Filter { + string field = 1; + Operator operator = 2; + string value = 3; + + enum Operator { + OPERATOR_UNSPECIFIED = 0; + OPERATOR_EQUAL = 1; + OPERATOR_NOT_EQUAL = 2; + OPERATOR_CONTAINS = 3; + OPERATOR_GREATER_THAN = 4; + OPERATOR_LESS_THAN = 5; + OPERATOR_IN = 6; + OPERATOR_EXISTS = 7; + } +} + +// --------------------------------------------------------------------------- +// Call & telephony enums +// --------------------------------------------------------------------------- + +// Strongly typed call type. Replaces the raw string field in v2 entities. +enum CallType { + CALL_TYPE_UNSPECIFIED = 0; + CALL_TYPE_INBOUND = 1; + CALL_TYPE_OUTBOUND = 2; + CALL_TYPE_PREVIEW = 3; + CALL_TYPE_MANUAL = 4; + CALL_TYPE_MAC = 5; +} + +// Telephony call outcome, split into category + detail for easier handling. +// Replaces the 60+ flat Result enum from v2. +message TelephonyOutcome { + Category category = 1; + Detail detail = 2; + + enum Category { + CATEGORY_UNSPECIFIED = 0; + CATEGORY_ANSWERED = 1; + CATEGORY_NO_ANSWER = 2; + CATEGORY_BUSY = 3; + CATEGORY_MACHINE = 4; + CATEGORY_INVALID = 5; + CATEGORY_FAILED = 6; + CATEGORY_CANCELED = 7; + CATEGORY_PENDING = 8; + } + + // Fine-grained detail within a category. Not all details apply to all + // categories. Consumers should switch on category first, then detail. + enum Detail { + DETAIL_UNSPECIFIED = 0; + DETAIL_GENERIC = 1; + // Answered details + DETAIL_LINKCALL = 2; + DETAIL_LINKCALL_NO_AGENT = 3; + DETAIL_MACHINE_HANGUP = 4; + DETAIL_MACHINE_DELIVERED = 5; + // No-answer details + DETAIL_NO_ANSWER_TIMEOUT = 6; + // Invalid details + DETAIL_INVALID_SIP_INVITE = 7; + DETAIL_INVALID_DESTINATION = 8; + DETAIL_DISCONNECTED_NUMBER = 9; + // Failed details + DETAIL_DNC_CHECK_PERSONAL = 10; + DETAIL_DNC_CHECK_NATIONAL = 11; + DETAIL_COMPLIANCE_BLOCK = 12; + DETAIL_CARRIER_REJECT = 13; + DETAIL_INTERNAL_ERROR = 14; + // Canceled details + DETAIL_AGENT_CANCEL = 15; + DETAIL_SYSTEM_CANCEL = 16; + } +} + +enum TelephonyStatus { + TELEPHONY_STATUS_UNSPECIFIED = 0; + TELEPHONY_STATUS_SCHEDULED = 1; + TELEPHONY_STATUS_RUNNING = 2; + TELEPHONY_STATUS_COMPLETED = 3; +} + +// --------------------------------------------------------------------------- +// Agent & caller state enums +// --------------------------------------------------------------------------- + +enum AgentState { + AGENT_STATE_UNSPECIFIED = 0; + AGENT_STATE_IDLE = 1; + AGENT_STATE_READY = 2; + AGENT_STATE_PEERED = 3; + AGENT_STATE_PAUSED = 4; + AGENT_STATE_WRAPUP = 5; + AGENT_STATE_PREPARING_PREVIEW = 6; + AGENT_STATE_PREVIEWING = 7; + AGENT_STATE_PREPARING_MANUAL = 8; + AGENT_STATE_DIALING = 9; + AGENT_STATE_INTERCOM = 10; + AGENT_STATE_WARM_TRANSFER_PENDING = 11; + AGENT_STATE_WARM_TRANSFER_ACTIVE = 12; + AGENT_STATE_COLD_TRANSFER_ACTIVE = 13; + AGENT_STATE_CONFERENCE_ACTIVE = 14; + AGENT_STATE_LOGGED_OUT = 15; + AGENT_STATE_SUSPENDED = 16; + AGENT_STATE_EXTERNAL_TRANSFER = 17; + AGENT_STATE_CALLBACK_SUSPENDED = 18; +} + +// --------------------------------------------------------------------------- +// Telephony & agent entities (fixed) +// --------------------------------------------------------------------------- + +message AgentCall { + int64 agent_call_sid = 1; + int64 call_sid = 2; + CallType call_type = 3; // was string in v2 + string org_id = 4; + string user_id = 5; + string partner_agent_id = 6; // was field #100 in v2 + string internal_key = 7; + + // Durations as proper Duration messages instead of raw int64. + google.protobuf.Duration talk_duration = 10; + google.protobuf.Duration wait_duration = 11; + google.protobuf.Duration wrapup_duration = 12; + google.protobuf.Duration pause_duration = 13; + google.protobuf.Duration transfer_duration = 14; + google.protobuf.Duration manual_duration = 15; + google.protobuf.Duration preview_duration = 16; + google.protobuf.Duration hold_duration = 17; + google.protobuf.Duration agent_wait_duration = 18; + google.protobuf.Duration suspended_duration = 19; + google.protobuf.Duration external_transfer_duration = 20; + + google.protobuf.Timestamp create_time = 30; + google.protobuf.Timestamp update_time = 31; + + // Replaces parallel task_data_keys/task_data_values arrays. + repeated TaskData task_data = 40; +} + +message TelephonyResult { + int64 call_sid = 1; + CallType call_type = 2; // was string in v2 + string org_id = 3; + string internal_key = 4; + string caller_id = 5; + string phone_number = 6; + string pool_id = 7; + string record_id = 8; + int64 client_sid = 9; + + TelephonyStatus status = 10; + TelephonyOutcome outcome = 11; // was flat 60+ Result enum in v2 + + google.protobuf.Duration delivery_length = 12; + google.protobuf.Duration linkback_length = 13; + + google.protobuf.Timestamp create_time = 20; + google.protobuf.Timestamp update_time = 21; + google.protobuf.Timestamp start_time = 22; + google.protobuf.Timestamp end_time = 23; + + // Replaces parallel task_data_keys/task_data_values arrays. + repeated TaskData task_data = 30; + + // Callback resume tracking. + optional int64 old_call_sid = 40; + optional CallType old_call_type = 41; + optional google.protobuf.Timestamp task_waiting_until = 42; +} + +message AgentResponse { + int64 agent_call_response_sid = 1; + int64 call_sid = 2; + CallType call_type = 3; // was string in v2 + string org_id = 4; + string user_id = 5; + string partner_agent_id = 6; // was field #100 in v2 + string internal_key = 7; + int64 agent_sid = 8; + int64 client_sid = 9; + string response_key = 10; + string response_value = 11; + google.protobuf.Timestamp create_time = 20; + google.protobuf.Timestamp update_time = 21; +} + +message TransferInstance { + int64 client_sid = 1; + string org_id = 2; + int64 transfer_instance_id = 3; + + Source source = 4; + Destination destination = 5; + + TransferType transfer_type = 6; + TransferResult transfer_result = 7; + + // Initiation flags. In v2 these were ambiguous booleans. + TransferInitiation initiation = 8; + + google.protobuf.Timestamp create_time = 10; + google.protobuf.Timestamp transfer_time = 11; + google.protobuf.Timestamp accept_time = 12; + google.protobuf.Timestamp hangup_time = 13; + google.protobuf.Timestamp end_time = 14; + google.protobuf.Timestamp update_time = 15; + + google.protobuf.Duration pending_duration = 20; + google.protobuf.Duration external_duration = 21; + google.protobuf.Duration full_duration = 22; + + // Source of the transfer (the agent initiating). + message Source { + int64 call_sid = 1; + CallType call_type = 2; + string partner_agent_id = 3; + string user_id = 4; + string conversation_id = 5; + int64 session_sid = 6; + int64 agent_call_sid = 7; + } + + // Destination of the transfer. Exactly one must be set. + // Skills apply only to queue-based destinations. + message Destination { + oneof target { + AgentTarget agent = 1; + OutboundTarget outbound = 2; + QueueTarget queue = 3; + } + + message AgentTarget { + int64 session_sid = 1; + string partner_agent_id = 2; + string user_id = 3; + } + + message OutboundTarget { + string phone_number = 1; + int64 call_sid = 2; + CallType call_type = 3; + string conversation_id = 4; + } + + // Queue-based routing with skill requirements. + // Replaces the loose map that sat outside the oneof in v2. + message QueueTarget { + map required_skills = 1; + } + } + + enum TransferType { + TRANSFER_TYPE_UNSPECIFIED = 0; + TRANSFER_TYPE_WARM_AGENT = 1; + TRANSFER_TYPE_WARM_CALLER = 2; + TRANSFER_TYPE_WARM_OUTBOUND = 3; + TRANSFER_TYPE_WARM_SKILL = 4; + TRANSFER_TYPE_COLD_AGENT = 5; + TRANSFER_TYPE_COLD_OUTBOUND = 6; + TRANSFER_TYPE_CONFERENCE = 7; + } + + enum TransferResult { + TRANSFER_RESULT_UNSPECIFIED = 0; + TRANSFER_RESULT_ACCEPTED = 1; + TRANSFER_RESULT_AGENT_CANCEL = 2; + TRANSFER_RESULT_CALLER_HANGUP = 3; + TRANSFER_RESULT_DESTINATION_HANGUP = 4; + } + + // Replaces the two ambiguous boolean flags (start_as_pending, + // started_as_conference) with an explicit enum. + enum TransferInitiation { + TRANSFER_INITIATION_UNSPECIFIED = 0; + TRANSFER_INITIATION_DIRECT = 1; + TRANSFER_INITIATION_PENDING = 2; + TRANSFER_INITIATION_CONFERENCE = 3; + } +} + +message CallRecording { + string recording_id = 1; + string org_id = 2; + int64 call_sid = 3; + CallType call_type = 4; + google.protobuf.Duration duration = 5; + RecordingType recording_type = 6; + google.protobuf.Timestamp start_time = 7; + + enum RecordingType { + RECORDING_TYPE_UNSPECIFIED = 0; + RECORDING_TYPE_TCN = 1; + RECORDING_TYPE_EXTERNAL = 2; + RECORDING_TYPE_VOICEMAIL = 3; + } +} + +message Task { + int64 task_sid = 1; + int64 task_group_sid = 2; + string org_id = 3; + int64 client_sid = 4; + string pool_id = 5; + string record_id = 6; + int64 attempts = 7; + TaskStatus status = 8; + google.protobuf.Timestamp create_time = 10; + google.protobuf.Timestamp update_time = 11; + + enum TaskStatus { + TASK_STATUS_UNSPECIFIED = 0; + TASK_STATUS_SCHEDULED = 1; + TASK_STATUS_WAITING = 2; + TASK_STATUS_PREPARING = 3; + TASK_STATUS_RUNNING = 4; + TASK_STATUS_COMPLETED = 5; + TASK_STATUS_CANCELLED_SYSTEM = 6; + TASK_STATUS_CANCELLED_ADMIN = 7; + } +} + +// --------------------------------------------------------------------------- +// Agent entity +// --------------------------------------------------------------------------- + +message Agent { + string user_id = 1; + string org_id = 2; + string first_name = 3; + string last_name = 4; + string username = 5; + string partner_agent_id = 6; + string current_session_id = 7; + AgentState agent_state = 8; + bool is_logged_in = 9; + bool is_muted = 10; + bool is_recording = 11; + optional ConnectedParty connected_party = 12; + + message ConnectedParty { + int64 call_sid = 1; + CallType call_type = 2; + bool is_inbound = 3; + } +} + +// --------------------------------------------------------------------------- +// Skill +// --------------------------------------------------------------------------- + +message Skill { + string skill_id = 1; + string name = 2; + string description = 3; + // Only set when the skill is assigned to an agent. v2 had this as a + // non-optional int64, making it impossible to distinguish "not assigned" + // from "proficiency = 0". + optional int64 proficiency = 4; +} + +// --------------------------------------------------------------------------- +// Scrub list +// --------------------------------------------------------------------------- + +message ScrubList { + string scrub_list_id = 1; + bool read_only = 2; + ContentType content_type = 3; + + enum ContentType { + CONTENT_TYPE_UNSPECIFIED = 0; + CONTENT_TYPE_PHONE_NUMBER = 1; + CONTENT_TYPE_EMAIL = 2; + CONTENT_TYPE_SMS = 3; + CONTENT_TYPE_ACCOUNT_NUMBER = 4; + CONTENT_TYPE_WHATSAPP = 5; + CONTENT_TYPE_OTHER = 6; + } +} + +message ScrubListEntry { + string content = 1; + optional google.protobuf.Timestamp expiration = 2; + optional string notes = 3; + optional string country_code = 4; +} diff --git a/core/proto/tcnapi/exile/worker/v3/service.proto b/core/proto/tcnapi/exile/worker/v3/service.proto new file mode 100644 index 0000000..bac54cd --- /dev/null +++ b/core/proto/tcnapi/exile/worker/v3/service.proto @@ -0,0 +1,465 @@ +// Worker service: unified work stream protocol. +// +// Replaces the three separate connections in gate/v2: +// - JobQueueStream (bidirectional gRPC) — job dispatch + ACK +// - EventStream (bidirectional gRPC) — event dispatch + ACK +// - SubmitJobResults (unary RPC) — result submission +// +// Design principles: +// 1. Single bidirectional stream for all work dispatch and results. +// 2. Client-driven pull with credit-based flow control (backpressure). +// 3. Every work item has a lease/deadline — no hung jobs. +// 4. Results flow on the same stream — no separate RPC. +// 5. Jobs and events are both "work items" — unified dispatch. + +syntax = "proto3"; + +package tcnapi.exile.worker.v3; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; +import "tcnapi/exile/types/v3/types.proto"; + +option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/worker/v3;workerv3"; +option java_multiple_files = true; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +service WorkerService { + // Single bidirectional stream replacing JobQueueStream, EventStream, + // and SubmitJobResults. + // + // Protocol: + // 1. Client opens stream, sends Register. + // 2. Server responds with Registered (confirms client, sets heartbeat). + // 3. Client sends Pull(max_items=N) to request work. + // 4. Server sends up to N WorkItems, each with a lease deadline. + // 5. Client processes work: + // - For jobs: sends Result with the job output. + // - For events: sends Ack to confirm handling. + // 6. Each Result/Ack frees one unit of capacity. Client sends + // additional Pull to request more work when ready. + // 7. If a lease expires before Result/Ack, server reclaims the + // work item and may dispatch it to another client. + // 8. Client can send ExtendLease to get more time on a work item. + // 9. Both sides send Heartbeat periodically to detect stale connections. + // + // Flow control: + // The server NEVER sends more WorkItems than the client has requested + // via Pull. The client controls its own concurrency by choosing when + // and how many items to pull. This eliminates the need for external + // semaphores or queue executors. + // + // Failure semantics: + // - Stream disconnect: all leased items whose deadlines haven't passed + // remain "in progress" until their lease expires, then get reclaimed. + // - Explicit Nack: client can reject a work item, making it immediately + // available for redelivery. + // - Result delivery is confirmed by ResultAccepted from server, giving + // the client a delivery guarantee (unlike fire-and-forget in v2). + rpc WorkStream(stream WorkRequest) returns (stream WorkResponse); +} + +// --------------------------------------------------------------------------- +// Client -> Server messages +// --------------------------------------------------------------------------- + +message WorkRequest { + oneof action { + Register register = 1; + Pull pull = 2; + Result result = 3; + Ack ack = 4; + Nack nack = 5; + ExtendLease extend_lease = 6; + Heartbeat heartbeat = 7; + } +} + +// First message on stream. Identifies the client and its capabilities. +message Register { + // Human-readable client name (e.g., "sati-finvi-prod-1"). + string client_name = 1; + + // Client software version for diagnostics. + string client_version = 2; + + // Work types this client can handle. Empty means "all types". + // Allows server to route specific work to capable clients. + repeated WorkType capabilities = 3; +} + +// Request work items. Client controls concurrency by choosing max_items. +// +// Sending Pull(max_items=5) means "I can accept 5 more items." +// The server tracks outstanding capacity per client: +// capacity += pull.max_items (on Pull) +// capacity -= 1 (on each WorkItem sent) +// Client typically sends Pull after completing items to replenish. +message Pull { + int32 max_items = 1; +} + +// Submit the result of a completed job. Server confirms with ResultAccepted. +message Result { + string work_id = 1; + + // For large results, send multiple Result messages with the same work_id. + // Set to true on the final message. Server accumulates chunks until this + // is true. The lease remains active during chunked submission. + bool final = 2; + + oneof payload { + ListPoolsResult list_pools = 10; + GetPoolStatusResult get_pool_status = 11; + GetPoolRecordsResult get_pool_records = 12; + SearchRecordsResult search_records = 13; + GetRecordFieldsResult get_record_fields = 14; + SetRecordFieldsResult set_record_fields = 15; + CreatePaymentResult create_payment = 16; + PopAccountResult pop_account = 17; + ExecuteLogicResult execute_logic = 18; + InfoResult info = 19; + ShutdownResult shutdown = 20; + LoggingResult logging = 21; + DiagnosticsResult diagnostics = 22; + ListTenantLogsResult list_tenant_logs = 23; + SetLogLevelResult set_log_level = 24; + ErrorResult error = 30; + } +} + +// Acknowledge processing of an event (no result data needed). +// Can batch multiple IDs for efficiency. +message Ack { + repeated string work_ids = 1; +} + +// Reject a work item. Makes it immediately available for another client. +// Use when the client cannot process the item (e.g., missing capability, +// database down for non-admin work). +message Nack { + string work_id = 1; + string reason = 2; +} + +// Request more time to process a work item. +message ExtendLease { + string work_id = 1; + google.protobuf.Duration extension = 2; +} + +message Heartbeat { + google.protobuf.Timestamp client_time = 1; +} + +// --------------------------------------------------------------------------- +// Server -> Client messages +// --------------------------------------------------------------------------- + +message WorkResponse { + oneof payload { + Registered registered = 1; + WorkItem work_item = 2; + ResultAccepted result_accepted = 3; + LeaseExtended lease_extended = 4; + LeaseExpiring lease_expiring = 5; + Heartbeat heartbeat = 6; + StreamError error = 7; + NackAccepted nack_accepted = 8; + } +} + +// Response to Register. Tells client the heartbeat interval and +// any server-imposed configuration. +message Registered { + string client_id = 1; + google.protobuf.Duration heartbeat_interval = 2; + google.protobuf.Duration default_lease = 3; + // Maximum items the server will dispatch per Pull. Client may request + // more, but server caps at this value. + int32 max_inflight = 4; +} + +// A unit of work dispatched to the client. +message WorkItem { + // Server-assigned unique identifier. + string work_id = 1; + + // Absolute deadline. If the client does not send a Result or Ack + // by this time, the item is reclaimed and eligible for redelivery. + google.protobuf.Timestamp deadline = 2; + + // Whether this item requires a Result (JOB) or just an Ack (EVENT). + WorkCategory category = 3; + + // Delivery attempt number. 1 = first delivery, 2+ = redelivery after + // lease expiry or nack. Clients can use this for deduplication or + // to detect poison-pill items. + int32 attempt = 4; + + oneof task { + // --- Jobs (require Result) --- + ListPoolsTask list_pools = 10; + GetPoolStatusTask get_pool_status = 11; + GetPoolRecordsTask get_pool_records = 12; + SearchRecordsTask search_records = 13; + GetRecordFieldsTask get_record_fields = 14; + SetRecordFieldsTask set_record_fields = 15; + CreatePaymentTask create_payment = 16; + PopAccountTask pop_account = 17; + ExecuteLogicTask execute_logic = 18; + InfoTask info = 19; + ShutdownTask shutdown = 20; + LoggingTask logging = 21; + DiagnosticsTask diagnostics = 22; + ListTenantLogsTask list_tenant_logs = 23; + SetLogLevelTask set_log_level = 24; + + // --- Events (require Ack only) --- + types.v3.AgentCall agent_call = 50; + types.v3.TelephonyResult telephony_result = 51; + types.v3.AgentResponse agent_response = 52; + types.v3.TransferInstance transfer_instance = 53; + types.v3.CallRecording call_recording = 54; + types.v3.Task exile_task = 55; + } +} + +// Confirms the server received and persisted the result. +// This gives clients a delivery guarantee that v2 lacked. +message ResultAccepted { + string work_id = 1; +} + +message LeaseExtended { + string work_id = 1; + google.protobuf.Timestamp new_deadline = 2; +} + +// Warning sent when a lease is about to expire. Gives the client a +// chance to extend or rush completion. +message LeaseExpiring { + string work_id = 1; + google.protobuf.Timestamp deadline = 2; + // Time remaining until expiry. + google.protobuf.Duration remaining = 3; +} + +message NackAccepted { + string work_id = 1; +} + +// Non-fatal stream error (e.g., invalid work_id in a Result). +// Fatal errors close the stream with a gRPC status code. +message StreamError { + string work_id = 1; + string code = 2; + string message = 3; +} + +// --------------------------------------------------------------------------- +// Work categories and types +// --------------------------------------------------------------------------- + +enum WorkCategory { + WORK_CATEGORY_UNSPECIFIED = 0; + // Jobs require a Result response before the deadline. + WORK_CATEGORY_JOB = 1; + // Events require an Ack before the deadline. No result data needed. + WORK_CATEGORY_EVENT = 2; +} + +// Exhaustive list of work types for capability filtering. +enum WorkType { + WORK_TYPE_UNSPECIFIED = 0; + + // Jobs + WORK_TYPE_LIST_POOLS = 1; + WORK_TYPE_GET_POOL_STATUS = 2; + WORK_TYPE_GET_POOL_RECORDS = 3; + WORK_TYPE_SEARCH_RECORDS = 4; + WORK_TYPE_GET_RECORD_FIELDS = 5; + WORK_TYPE_SET_RECORD_FIELDS = 6; + WORK_TYPE_CREATE_PAYMENT = 7; + WORK_TYPE_POP_ACCOUNT = 8; + WORK_TYPE_EXECUTE_LOGIC = 9; + WORK_TYPE_INFO = 10; + WORK_TYPE_SHUTDOWN = 11; + WORK_TYPE_LOGGING = 12; + WORK_TYPE_DIAGNOSTICS = 13; + WORK_TYPE_LIST_TENANT_LOGS = 14; + WORK_TYPE_SET_LOG_LEVEL = 15; + + // Events + WORK_TYPE_AGENT_CALL = 50; + WORK_TYPE_TELEPHONY_RESULT = 51; + WORK_TYPE_AGENT_RESPONSE = 52; + WORK_TYPE_TRANSFER_INSTANCE = 53; + WORK_TYPE_CALL_RECORDING = 54; + WORK_TYPE_TASK = 55; +} + +// --------------------------------------------------------------------------- +// Job task payloads (server -> client) +// --------------------------------------------------------------------------- + +message ListPoolsTask {} + +message GetPoolStatusTask { + string pool_id = 1; +} + +message GetPoolRecordsTask { + string pool_id = 1; + // Page token for continuation. Empty on first request. + string page_token = 2; + int32 page_size = 3; +} + +message SearchRecordsTask { + repeated types.v3.Filter filters = 1; + string page_token = 2; + int32 page_size = 3; +} + +message GetRecordFieldsTask { + string pool_id = 1; + string record_id = 2; + // If empty, return all fields. + repeated string field_names = 3; +} + +message SetRecordFieldsTask { + string pool_id = 1; + string record_id = 2; + repeated types.v3.Field fields = 3; +} + +message CreatePaymentTask { + string pool_id = 1; + string record_id = 2; + google.protobuf.Struct payment_data = 3; +} + +message PopAccountTask { + string pool_id = 1; + string record_id = 2; +} + +message ExecuteLogicTask { + string logic_name = 1; + google.protobuf.Struct parameters = 2; +} + +message InfoTask {} + +message ShutdownTask { + string reason = 1; +} + +message LoggingTask { + string payload = 1; +} + +message DiagnosticsTask {} + +message ListTenantLogsTask { + google.protobuf.Timestamp start_time = 1; + google.protobuf.Timestamp end_time = 2; + string page_token = 3; + int32 page_size = 4; +} + +message SetLogLevelTask { + string logger_name = 1; + string level = 2; +} + +// --------------------------------------------------------------------------- +// Job result payloads (client -> server) +// --------------------------------------------------------------------------- + +message ListPoolsResult { + repeated types.v3.Pool pools = 1; +} + +message GetPoolStatusResult { + types.v3.Pool pool = 1; +} + +message GetPoolRecordsResult { + repeated types.v3.Record records = 1; + // If non-empty, more records are available. Server should dispatch + // a follow-up GetPoolRecordsTask with this token. + string next_page_token = 2; +} + +message SearchRecordsResult { + repeated types.v3.Record records = 1; + string next_page_token = 2; +} + +message GetRecordFieldsResult { + repeated types.v3.Field fields = 1; +} + +message SetRecordFieldsResult { + bool success = 1; +} + +message CreatePaymentResult { + bool success = 1; + string payment_id = 2; +} + +message PopAccountResult { + types.v3.Record record = 1; +} + +message ExecuteLogicResult { + google.protobuf.Struct output = 1; +} + +message InfoResult { + string app_name = 1; + string app_version = 2; + google.protobuf.Struct metadata = 3; +} + +message ShutdownResult {} + +message LoggingResult {} + +// Diagnostics as a generic Struct instead of 50+ Java-specific fields. +// Each runtime (Java, Go, Python) can populate what's relevant. +message DiagnosticsResult { + google.protobuf.Struct system_info = 1; + google.protobuf.Struct runtime_info = 2; + google.protobuf.Struct database_info = 3; + google.protobuf.Struct custom = 4; +} + +message ListTenantLogsResult { + repeated LogEntry entries = 1; + string next_page_token = 2; + + message LogEntry { + google.protobuf.Timestamp timestamp = 1; + string level = 2; + string logger = 3; + string message = 4; + } +} + +message SetLogLevelResult {} + +message ErrorResult { + string message = 1; + // Optional structured error details. + string code = 2; + google.protobuf.Struct details = 3; +} diff --git a/core/src/main/java/com/tcn/exile/handler/JobHandler.java b/core/src/main/java/com/tcn/exile/handler/JobHandler.java index 6d7425f..dd34eb4 100644 --- a/core/src/main/java/com/tcn/exile/handler/JobHandler.java +++ b/core/src/main/java/com/tcn/exile/handler/JobHandler.java @@ -24,12 +24,12 @@ default Pool getPoolStatus(String poolId) throws Exception { throw new UnsupportedOperationException("getPoolStatus not implemented"); } - default Page getPoolRecords(String poolId, String pageToken, int pageSize) + 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) + default Page searchRecords(List filters, String pageToken, int pageSize) throws Exception { throw new UnsupportedOperationException("searchRecords not implemented"); } @@ -49,7 +49,7 @@ default String createPayment(String poolId, String recordId, Map throw new UnsupportedOperationException("createPayment not implemented"); } - default Record popAccount(String poolId, String recordId) throws Exception { + default DataRecord popAccount(String poolId, String recordId) throws Exception { throw new UnsupportedOperationException("popAccount not implemented"); } @@ -76,8 +76,8 @@ default DiagnosticsInfo diagnostics() throws Exception { throw new UnsupportedOperationException("diagnostics not implemented"); } - default Page listTenantLogs(Instant startTime, Instant endTime, String pageToken, - int pageSize) throws Exception { + default Page listTenantLogs( + Instant startTime, Instant endTime, String pageToken, int pageSize) throws Exception { throw new UnsupportedOperationException("listTenantLogs not implemented"); } diff --git a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java index 178ac07..e13f378 100644 --- a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java +++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java @@ -1,7 +1,7 @@ package com.tcn.exile.internal; import com.tcn.exile.model.*; -import com.tcn.exile.model.Record; +import com.tcn.exile.model.DataRecord; import com.tcn.exile.model.event.*; import java.time.Duration; import java.time.Instant; @@ -19,7 +19,8 @@ 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; + if (d == null || d.equals(com.google.protobuf.Duration.getDefaultInstance())) + return Duration.ZERO; return Duration.ofSeconds(d.getSeconds(), d.getNanos()); } @@ -103,11 +104,11 @@ public static tcnapi.exile.types.v3.Pool fromPool(Pool p) { .build(); } - public static Record toRecord(tcnapi.exile.types.v3.Record r) { - return new Record(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload())); + public static DataRecord toRecord(tcnapi.exile.types.v3.Record r) { + return new DataRecord(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload())); } - public static tcnapi.exile.types.v3.Record fromRecord(Record r) { + public static tcnapi.exile.types.v3.Record fromRecord(DataRecord r) { return tcnapi.exile.types.v3.Record.newBuilder() .setPoolId(r.poolId()) .setRecordId(r.recordId()) @@ -229,7 +230,8 @@ public static AgentCallEvent toAgentCallEvent(tcnapi.exile.types.v3.AgentCall ac toTaskData(ac.getTaskDataList())); } - public static TelephonyResultEvent toTelephonyResultEvent(tcnapi.exile.types.v3.TelephonyResult tr) { + public static TelephonyResultEvent toTelephonyResultEvent( + tcnapi.exile.types.v3.TelephonyResult tr) { return new TelephonyResultEvent( tr.getCallSid(), toCallType(tr.getCallType()), @@ -280,7 +282,8 @@ public static CallRecordingEvent toCallRecordingEvent(tcnapi.exile.types.v3.Call toInstant(cr.getStartTime())); } - public static TransferInstanceEvent toTransferInstanceEvent(tcnapi.exile.types.v3.TransferInstance ti) { + public static TransferInstanceEvent toTransferInstanceEvent( + tcnapi.exile.types.v3.TransferInstance ti) { var src = ti.getSource(); return new TransferInstanceEvent( ti.getClientSid(), diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 621da15..a26f86b 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -12,7 +12,6 @@ import io.grpc.stub.StreamObserver; import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -208,16 +207,13 @@ private void handleResponse(WorkResponse response) { case LEASE_EXPIRING -> { var w = response.getLeaseExpiring(); log.debug( - "Lease expiring for {}, {}s remaining", - w.getWorkId(), - w.getRemaining().getSeconds()); + "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))) + .setExtension(com.google.protobuf.Duration.newBuilder().setSeconds(300))) .build()); } case HEARTBEAT -> @@ -232,8 +228,7 @@ private void handleResponse(WorkResponse response) { case ERROR -> { var err = response.getError(); lastError = err.getCode() + ": " + err.getMessage(); - log.warn( - "Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); + log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); } default -> {} } @@ -287,7 +282,10 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { jobHandler.getPoolRecords(task.getPoolId(), task.getPageToken(), task.getPageSize()); b.setGetPoolRecords( GetPoolRecordsResult.newBuilder() - .addAllRecords(page.items().stream().map(ProtoConverter::fromRecord).toList()) + .addAllRecords( + page.items().stream() + .map(r -> ProtoConverter.fromRecord(r)) + .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); } case SEARCH_RECORDS -> { @@ -296,7 +294,10 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { var page = jobHandler.searchRecords(filters, task.getPageToken(), task.getPageSize()); b.setSearchRecords( SearchRecordsResult.newBuilder() - .addAllRecords(page.items().stream().map(ProtoConverter::fromRecord).toList()) + .addAllRecords( + page.items().stream() + .map(r -> ProtoConverter.fromRecord(r)) + .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); } case GET_RECORD_FIELDS -> { @@ -400,7 +401,7 @@ private void dispatchEvent(WorkItem item) throws Exception { eventHandler.onTransferInstance(toTransferInstanceEvent(item.getTransferInstance())); case CALL_RECORDING -> eventHandler.onCallRecording(toCallRecordingEvent(item.getCallRecording())); - case TASK -> eventHandler.onTask(toTaskEvent(item.getTask())); + case EXILE_TASK -> eventHandler.onTask(toTaskEvent(item.getExileTask())); default -> throw new UnsupportedOperationException("Unknown event: " + item.getTaskCase()); } } 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/Record.java b/core/src/main/java/com/tcn/exile/model/Record.java deleted file mode 100644 index 88f1d83..0000000 --- a/core/src/main/java/com/tcn/exile/model/Record.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.tcn.exile.model; - -import java.util.Map; - -public record Record(String poolId, String recordId, Map payload) {} 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 index fd6357e..5dba985 100644 --- a/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java +++ b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java @@ -3,7 +3,6 @@ import com.tcn.exile.model.CallType; import java.time.Duration; import java.time.Instant; -import java.util.Map; public record TransferInstanceEvent( long clientSid, diff --git a/core/src/main/java/com/tcn/exile/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java index 8711a94..3cf1fdd 100644 --- a/core/src/main/java/com/tcn/exile/service/AgentService.java +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -19,8 +19,8 @@ public final class AgentService { } public Agent getAgentByPartnerId(String partnerAgentId) { - var resp = stub.getAgent( - GetAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + var resp = + stub.getAgent(GetAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); return toAgent(resp.getAgent()); } @@ -29,11 +29,16 @@ public Agent getAgentByUserId(String userId) { return toAgent(resp.getAgent()); } - public Page listAgents(Boolean loggedIn, AgentState state, - boolean includeRecordingStatus, String pageToken, int pageSize) { - var req = ListAgentsRequest.newBuilder() - .setIncludeRecordingStatus(includeRecordingStatus) - .setPageSize(pageSize); + public Page listAgents( + Boolean loggedIn, + AgentState state, + boolean includeRecordingStatus, + String pageToken, + int pageSize) { + var req = + 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); @@ -43,15 +48,16 @@ public Page listAgents(Boolean loggedIn, AgentState state, resp.getNextPageToken()); } - public Agent upsertAgent(String partnerAgentId, String username, String firstName, - String lastName) { - var resp = stub.upsertAgent( - UpsertAgentRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setUsername(username) - .setFirstName(firstName) - .setLastName(lastName) - .build()); + public Agent upsertAgent( + String partnerAgentId, String username, String firstName, String lastName) { + var resp = + stub.upsertAgent( + UpsertAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setUsername(username) + .setFirstName(firstName) + .setLastName(lastName) + .build()); return toAgent(resp.getAgent()); } @@ -80,14 +86,18 @@ public void unmuteAgent(String partnerAgentId) { stub.unmuteAgent(UnmuteAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); } - public void addAgentCallResponse(String partnerAgentId, long callSid, CallType callType, - String sessionId, String key, String value) { + public void addAgentCallResponse( + String partnerAgentId, + long callSid, + CallType callType, + String sessionId, + String key, + String value) { stub.addAgentCallResponse( AddAgentCallResponseRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setCallSid(callSid) - .setCallType( - tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCallType(tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) .setCurrentSessionId(sessionId) .setKey(key) .setValue(value) @@ -100,8 +110,9 @@ public List listSkills() { } public List listAgentSkills(String partnerAgentId) { - var resp = stub.listAgentSkills( - ListAgentSkillsRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + var resp = + stub.listAgentSkills( + ListAgentSkillsRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); } diff --git a/core/src/main/java/com/tcn/exile/service/CallService.java b/core/src/main/java/com/tcn/exile/service/CallService.java index 5164971..bcf8105 100644 --- a/core/src/main/java/com/tcn/exile/service/CallService.java +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -24,12 +24,17 @@ public record DialResult( boolean attempted, String status) {} - public DialResult dial(String partnerAgentId, String phoneNumber, String callerId, - String poolId, String recordId, String rulesetName, Boolean skipCompliance, + public DialResult dial( + String partnerAgentId, + String phoneNumber, + String callerId, + String poolId, + String recordId, + String rulesetName, + Boolean skipCompliance, Boolean recordCall) { - var req = DialRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setPhoneNumber(phoneNumber); + var req = + DialRequest.newBuilder().setPartnerAgentId(partnerAgentId).setPhoneNumber(phoneNumber); if (callerId != null) req.setCallerId(callerId); if (poolId != null) req.setPoolId(poolId); if (recordId != null) req.setRecordId(recordId); @@ -48,34 +53,43 @@ public DialResult dial(String partnerAgentId, String phoneNumber, String callerI resp.getStatus()); } - public void transfer(String partnerAgentId, String kind, String action, - String destAgentId, String destPhone, Map destSkills) { + public void transfer( + String partnerAgentId, + String kind, + String action, + String destAgentId, + String destPhone, + Map destSkills) { var req = TransferRequest.newBuilder().setPartnerAgentId(partnerAgentId); req.setKind(TransferRequest.TransferKind.valueOf("TRANSFER_KIND_" + kind)); req.setAction(TransferRequest.TransferAction.valueOf("TRANSFER_ACTION_" + action)); if (destAgentId != null) { - req.setAgent(TransferRequest.AgentDestination.newBuilder() - .setPartnerAgentId(destAgentId)); + req.setAgent(TransferRequest.AgentDestination.newBuilder().setPartnerAgentId(destAgentId)); } else if (destPhone != null) { - req.setOutbound(TransferRequest.OutboundDestination.newBuilder() - .setPhoneNumber(destPhone)); + req.setOutbound(TransferRequest.OutboundDestination.newBuilder().setPhoneNumber(destPhone)); } else if (destSkills != null) { req.setQueue(TransferRequest.QueueDestination.newBuilder().putAllRequiredSkills(destSkills)); } stub.transfer(req.build()); } - public enum HoldTarget { CALL, TRANSFER_CALLER, TRANSFER_AGENT } - public enum HoldAction { HOLD, UNHOLD } + public enum HoldTarget { + CALL, + TRANSFER_CALLER, + TRANSFER_AGENT + } + + public enum HoldAction { + HOLD, + UNHOLD + } public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) { stub.setHoldState( SetHoldStateRequest.newBuilder() .setPartnerAgentId(partnerAgentId) - .setTarget(SetHoldStateRequest.HoldTarget.valueOf( - "HOLD_TARGET_" + target.name())) - .setAction(SetHoldStateRequest.HoldAction.valueOf( - "HOLD_ACTION_" + action.name())) + .setTarget(SetHoldStateRequest.HoldTarget.valueOf("HOLD_TARGET_" + target.name())) + .setAction(SetHoldStateRequest.HoldAction.valueOf("HOLD_ACTION_" + action.name())) .build()); } @@ -90,15 +104,13 @@ public void stopCallRecording(String partnerAgentId) { } public boolean getRecordingStatus(String partnerAgentId) { - return stub - .getRecordingStatus( + return stub.getRecordingStatus( GetRecordingStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()) .getIsRecording(); } public java.util.List listComplianceRulesets() { - return stub - .listComplianceRulesets(ListComplianceRulesetsRequest.getDefaultInstance()) + return stub.listComplianceRulesets(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 index 9a6ce19..976cca3 100644 --- a/core/src/main/java/com/tcn/exile/service/ConfigService.java +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -1,7 +1,7 @@ package com.tcn.exile.service; import com.tcn.exile.internal.ProtoConverter; -import com.tcn.exile.model.Record; +import com.tcn.exile.model.DataRecord; import io.grpc.ManagedChannel; import java.util.Map; import tcnapi.exile.config.v3.*; @@ -35,8 +35,9 @@ public OrgInfo getOrganizationInfo() { } public String rotateCertificate(String certificateHash) { - var resp = stub.rotateCertificate( - RotateCertificateRequest.newBuilder().setCertificateHash(certificateHash).build()); + var resp = + stub.rotateCertificate( + RotateCertificateRequest.newBuilder().setCertificateHash(certificateHash).build()); return resp.getEncodedCertificate(); } @@ -44,13 +45,20 @@ public void log(String payload) { stub.log(LogRequest.newBuilder().setPayload(payload).build()); } - public enum JourneyBufferStatus { INSERTED, UPDATED, IGNORED, REJECTED, UNSPECIFIED } + public enum JourneyBufferStatus { + INSERTED, + UPDATED, + IGNORED, + REJECTED, + UNSPECIFIED + } - public JourneyBufferStatus addRecordToJourneyBuffer(Record record) { - var resp = stub.addRecordToJourneyBuffer( - AddRecordToJourneyBufferRequest.newBuilder() - .setRecord(ProtoConverter.fromRecord(record)) - .build()); + public JourneyBufferStatus addRecordToJourneyBuffer(DataRecord record) { + var resp = + stub.addRecordToJourneyBuffer( + 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; diff --git a/core/src/main/java/com/tcn/exile/service/RecordingService.java b/core/src/main/java/com/tcn/exile/service/RecordingService.java index e63b397..f83a1cf 100644 --- a/core/src/main/java/com/tcn/exile/service/RecordingService.java +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -34,33 +34,36 @@ public record VoiceRecording( public record DownloadLinks(String downloadLink, String playbackLink) {} - public Page searchVoiceRecordings(List filters, String pageToken, - int pageSize) { + public Page searchVoiceRecordings( + List filters, String pageToken, int pageSize) { var req = 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()); + 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) { + public DownloadLinks getDownloadLink( + String recordingId, Duration startOffset, Duration endOffset) { var req = GetDownloadLinkRequest.newBuilder().setRecordingId(recordingId); if (startOffset != null) req.setStartOffset(ProtoConverter.fromDuration(startOffset)); if (endOffset != null) req.setEndOffset(ProtoConverter.fromDuration(endOffset)); @@ -69,8 +72,7 @@ public DownloadLinks getDownloadLink(String recordingId, Duration startOffset, } public List listSearchableFields() { - return stub - .listSearchableFields(ListSearchableFieldsRequest.getDefaultInstance()) + return stub.listSearchableFields(ListSearchableFieldsRequest.getDefaultInstance()) .getFieldsList(); } @@ -78,8 +80,7 @@ public void createLabel(long callSid, CallType callType, String key, String valu stub.createLabel( CreateLabelRequest.newBuilder() .setCallSid(callSid) - .setCallType( - tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCallType(tcnapi.exile.types.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 index 9694d3c..5ca8ec0 100644 --- a/core/src/main/java/com/tcn/exile/service/ScrubListService.java +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -20,22 +20,23 @@ public record ScrubListEntry( String content, Instant expiration, String notes, String countryCode) {} public List listScrubLists() { - return stub.listScrubLists(ListScrubListsRequest.getDefaultInstance()).getScrubListsList() + return stub + .listScrubLists(ListScrubListsRequest.getDefaultInstance()) + .getScrubListsList() .stream() - .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), - sl.getContentType().name())) + .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), sl.getContentType().name())) .toList(); } - public void addEntries(String scrubListId, List entries, - String defaultCountryCode) { + public void addEntries( + String scrubListId, List entries, String defaultCountryCode) { var req = AddEntriesRequest.newBuilder().setScrubListId(scrubListId); if (defaultCountryCode != null) req.setDefaultCountryCode(defaultCountryCode); for (var e : entries) { var eb = tcnapi.exile.types.v3.ScrubListEntry.newBuilder().setContent(e.content()); if (e.expiration() != null) { - eb.setExpiration(com.google.protobuf.Timestamp.newBuilder() - .setSeconds(e.expiration().getEpochSecond())); + 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()); @@ -47,8 +48,9 @@ public void addEntries(String scrubListId, List entries, public void updateEntry(String scrubListId, ScrubListEntry entry) { var eb = tcnapi.exile.types.v3.ScrubListEntry.newBuilder().setContent(entry.content()); if (entry.expiration() != null) { - eb.setExpiration(com.google.protobuf.Timestamp.newBuilder() - .setSeconds(entry.expiration().getEpochSecond())); + 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()); diff --git a/gradle.properties b/gradle.properties index 055b33e..db1c65c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,5 @@ grpcVersion=1.68.1 -protobufVersion=3.25.5 -exileapiProtobufVersion=34.1.0.1.20260323182149.1e342050752a -exileapiGrpcVersion=1.80.0.1.20260323182149.1e342050752a +protobufVersion=4.28.3 org.gradle.jvmargs=-Xmx4G From d2365fbf3f173a8302c14c7df70d63e843f29d30 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:12:15 -0600 Subject: [PATCH 07/50] Add test coverage for core and config modules 62 tests across 7 test classes, all passing. core module (53 tests): - BackoffTest: exponential growth, jitter bounds, cap at 30s, reset - ProtoConverterTest: round-trip conversion for Duration, Timestamp, CallType, AgentState, Pool (all statuses), DataRecord, Field, Filter (all operators), Struct/Map (nested, lists, nulls), TaskData, Agent (with/without connected party), Skill, and all event types (AgentCall, TelephonyResult, CallRecording, Task) - ModelTest: Page.hasMore(), record equality, enum value counts - StreamStatusTest: isHealthy() for all phases - ExileConfigTest: builder, default port, null validation config module (9 tests): - ConfigParserTest: Base64 encoded, raw JSON, missing port, missing endpoint, missing certs, garbage input, empty input, trailing newline, escaped newlines in certificate values Added grpc-testing and grpc-inprocess test dependencies to core. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tcn/exile/config/ConfigParserTest.java | 120 ++++++ core/build.gradle | 4 + .../java/com/tcn/exile/ExileConfigTest.java | 45 ++ .../java/com/tcn/exile/StreamStatusTest.java | 49 +++ .../com/tcn/exile/internal/BackoffTest.java | 56 +++ .../exile/internal/ProtoConverterTest.java | 396 ++++++++++++++++++ .../java/com/tcn/exile/model/ModelTest.java | 66 +++ 7 files changed, 736 insertions(+) create mode 100644 config/src/test/java/com/tcn/exile/config/ConfigParserTest.java create mode 100644 core/src/test/java/com/tcn/exile/ExileConfigTest.java create mode 100644 core/src/test/java/com/tcn/exile/StreamStatusTest.java create mode 100644 core/src/test/java/com/tcn/exile/internal/BackoffTest.java create mode 100644 core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java create mode 100644 core/src/test/java/com/tcn/exile/model/ModelTest.java 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 d2cea0b..9b21dfd 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -39,6 +39,10 @@ dependencies { // javax.annotation for @Generated in gRPC stubs compileOnly("org.apache.tomcat:annotations-api:6.0.53") + + // test: in-process gRPC server for service client tests + testImplementation("io.grpc:grpc-testing:${grpcVersion}") + testImplementation("io.grpc:grpc-inprocess:${grpcVersion}") } jacoco { 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..dfda02d --- /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 >= 1600 && d1 <= 2400, "First failure ~2s, got " + d1); + + b.recordFailure(); + long d2 = b.nextDelayMs(); + assertTrue(d2 >= 3200 && d2 <= 4800, "Second failure ~4s, got " + d2); + + b.recordFailure(); + long d3 = b.nextDelayMs(); + assertTrue(d3 >= 6400 && d3 <= 9600, "Third failure ~8s, got " + d3); + } + + @Test + void delayCapsAtMax() { + var b = new Backoff(); + for (int i = 0; i < 20; i++) b.recordFailure(); + long d = b.nextDelayMs(); + assertTrue(d <= 30_000, "Should cap at 30s, 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/ProtoConverterTest.java b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java new file mode 100644 index 0000000..071678d --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java @@ -0,0 +1,396 @@ +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(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND)); + assertEquals( + CallType.OUTBOUND, + ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_OUTBOUND)); + assertEquals( + CallType.PREVIEW, + ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_PREVIEW)); + assertEquals( + CallType.MANUAL, + ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_MANUAL)); + assertEquals( + CallType.MAC, ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_MAC)); + assertEquals( + CallType.UNSPECIFIED, + ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_UNSPECIFIED)); + } + + // ---- AgentState ---- + + @Test + void agentStateRoundTrip() { + var proto = tcnapi.exile.types.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(tcnapi.exile.types.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(tcnapi.exile.types.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( + tcnapi.exile.types.v3.TaskData.newBuilder() + .setKey("pool_id") + .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1").build()) + .build(), + tcnapi.exile.types.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 = + tcnapi.exile.types.v3.Agent.newBuilder() + .setUserId("U-1") + .setOrgId("O-1") + .setFirstName("John") + .setLastName("Doe") + .setUsername("jdoe") + .setPartnerAgentId("PA-1") + .setCurrentSessionId("S-1") + .setAgentState(tcnapi.exile.types.v3.AgentState.AGENT_STATE_READY) + .setIsLoggedIn(true) + .setIsMuted(false) + .setIsRecording(true) + .setConnectedParty( + tcnapi.exile.types.v3.Agent.ConnectedParty.newBuilder() + .setCallSid(42) + .setCallType(tcnapi.exile.types.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 = tcnapi.exile.types.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 = + tcnapi.exile.types.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 = + tcnapi.exile.types.v3.AgentCall.newBuilder() + .setAgentCallSid(1) + .setCallSid(2) + .setCallType(tcnapi.exile.types.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( + tcnapi.exile.types.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 = + tcnapi.exile.types.v3.TelephonyResult.newBuilder() + .setCallSid(42) + .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND) + .setOrgId("O-1") + .setCallerId("+15551234567") + .setPhoneNumber("+15559876543") + .setPoolId("P-1") + .setRecordId("R-1") + .setStatus(tcnapi.exile.types.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) + .setOutcome( + tcnapi.exile.types.v3.TelephonyOutcome.newBuilder() + .setCategory(tcnapi.exile.types.v3.TelephonyOutcome.Category.CATEGORY_ANSWERED) + .setDetail(tcnapi.exile.types.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 = + tcnapi.exile.types.v3.CallRecording.newBuilder() + .setRecordingId("REC-1") + .setOrgId("O-1") + .setCallSid(42) + .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND) + .setDuration(com.google.protobuf.Duration.newBuilder().setSeconds(300)) + .setRecordingType(tcnapi.exile.types.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 = + tcnapi.exile.types.v3.Task.newBuilder() + .setTaskSid(1) + .setTaskGroupSid(2) + .setOrgId("O-1") + .setPoolId("P-1") + .setRecordId("R-1") + .setAttempts(3) + .setStatus(tcnapi.exile.types.v3.Task.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/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()); + } +} From 0aa09017745a9f6c34840859f86be76460391165 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:13:00 -0600 Subject: [PATCH 08/50] Switch to pre-built buf.build Maven deps for v3 protos Replace local buf proto generation with pre-built artifacts from buf.build BSR, now that exileapi v3 is merged to master. Changes: - Remove build.buf gradle plugin, local proto files, buf.yaml/gen/lock - Add exileapi version pins in gradle.properties pointing to the published v3 artifacts (commit 461190882f3b) - Update all Java imports from old sub-packages (tcnapi.exile.types.v3, tcnapi.exile.worker.v3, etc.) to flat package with BSR prefix (build.buf.gen.tcnapi.exile.v3) - Service files use FQN for proto types to avoid ambiguity with identically-named model classes (Pool, Agent, Filter, etc.) - Move buf Maven repo declaration to root allprojects block Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 5 +- core/buf.gen.yaml | 7 - core/buf.lock | 6 - core/buf.yaml | 13 - core/build.gradle | 26 +- .../proto/tcnapi/exile/agent/v3/service.proto | 256 ---------- core/proto/tcnapi/exile/call/v3/service.proto | 207 -------- .../tcnapi/exile/config/v3/service.proto | 98 ---- .../tcnapi/exile/recording/v3/service.proto | 103 ---- .../tcnapi/exile/scrublist/v3/service.proto | 68 --- core/proto/tcnapi/exile/types/v3/types.proto | 454 ----------------- .../tcnapi/exile/worker/v3/service.proto | 465 ------------------ .../main/java/com/tcn/exile/ExileClient.java | 2 +- .../tcn/exile/internal/ProtoConverter.java | 79 +-- .../tcn/exile/internal/WorkStreamClient.java | 8 +- .../com/tcn/exile/service/AgentService.java | 48 +- .../com/tcn/exile/service/CallService.java | 58 ++- .../com/tcn/exile/service/ConfigService.java | 21 +- .../tcn/exile/service/RecordingService.java | 22 +- .../tcn/exile/service/ScrubListService.java | 22 +- .../exile/internal/ProtoConverterTest.java | 69 +-- gradle.properties | 3 + 22 files changed, 209 insertions(+), 1831 deletions(-) delete mode 100644 core/buf.gen.yaml delete mode 100644 core/buf.lock delete mode 100644 core/buf.yaml delete mode 100644 core/proto/tcnapi/exile/agent/v3/service.proto delete mode 100644 core/proto/tcnapi/exile/call/v3/service.proto delete mode 100644 core/proto/tcnapi/exile/config/v3/service.proto delete mode 100644 core/proto/tcnapi/exile/recording/v3/service.proto delete mode 100644 core/proto/tcnapi/exile/scrublist/v3/service.proto delete mode 100644 core/proto/tcnapi/exile/types/v3/types.proto delete mode 100644 core/proto/tcnapi/exile/worker/v3/service.proto diff --git a/build.gradle b/build.gradle index e61d077..9fc6e77 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,5 @@ plugins { id("com.github.johnrengelman.shadow") version "8.1.1" apply(false) - id("build.buf") version "0.11.0" apply(false) id("maven-publish") id("com.diffplug.spotless") version "7.0.3" } @@ -16,6 +15,10 @@ allprojects { repositories { mavenCentral() + maven { + name = 'buf' + url 'https://buf.build/gen/maven' + } } java { diff --git a/core/buf.gen.yaml b/core/buf.gen.yaml deleted file mode 100644 index b42ac6a..0000000 --- a/core/buf.gen.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: v2 -clean: true -plugins: - - remote: buf.build/protocolbuffers/java:v28.3 - out: java - - remote: buf.build/grpc/java:v1.68.1 - out: java diff --git a/core/buf.lock b/core/buf.lock deleted file mode 100644 index ab5dd97..0000000 --- a/core/buf.lock +++ /dev/null @@ -1,6 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v2 -deps: - - name: buf.build/googleapis/googleapis - commit: 536964a08a534d51b8f30f2d6751f1f9 - digest: b5:3e05d27e797b00c345fadd3c15cf0e16c4cc693036a55059721e66d6ce22a96264a4897658c9243bb0874fa9ca96e437589eb512189d2754604a626c632f6030 diff --git a/core/buf.yaml b/core/buf.yaml deleted file mode 100644 index fbd9764..0000000 --- a/core/buf.yaml +++ /dev/null @@ -1,13 +0,0 @@ -version: v2 -modules: - - path: proto -deps: - - buf.build/googleapis/googleapis -lint: - use: - - STANDARD - except: - - RPC_REQUEST_STANDARD_NAME - - RPC_RESPONSE_STANDARD_NAME - - ENUM_VALUE_PREFIX - - ENUM_ZERO_VALUE_SUFFIX diff --git a/core/build.gradle b/core/build.gradle index 9b21dfd..a7dfa6c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,33 +1,17 @@ plugins { id("java-library") - id("build.buf") id("com.github.johnrengelman.shadow") id("maven-publish") id("jacoco") } -// Generate Java + gRPC stubs from exileapi protos via buf. -buf { - configFileLocation = file("buf.yaml") - generate { - templateFileLocation = file("buf.gen.yaml") - includeImports = true - } -} - -// Wire buf generation into the compile lifecycle. -tasks.named("compileJava").configure { dependsOn("bufGenerate") } -// Add generated sources to the main source set. -sourceSets { - main { - java { - srcDir("${buildDir}/bufbuild/generated/java") - } - } -} 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}") + // gRPC — client only api("io.grpc:grpc-api:${grpcVersion}") api("io.grpc:grpc-protobuf:${grpcVersion}") @@ -40,7 +24,7 @@ dependencies { // javax.annotation for @Generated in gRPC stubs compileOnly("org.apache.tomcat:annotations-api:6.0.53") - // test: in-process gRPC server for service client tests + // test testImplementation("io.grpc:grpc-testing:${grpcVersion}") testImplementation("io.grpc:grpc-inprocess:${grpcVersion}") } diff --git a/core/proto/tcnapi/exile/agent/v3/service.proto b/core/proto/tcnapi/exile/agent/v3/service.proto deleted file mode 100644 index a7bdca3..0000000 --- a/core/proto/tcnapi/exile/agent/v3/service.proto +++ /dev/null @@ -1,256 +0,0 @@ -// Agent management service. -// -// Extracted from gate/v2 GateService. Handles agent CRUD, state -// management, muting, skills, and pause codes. -// -// Changes from v2: -// - GetAgentById and GetAgentByPartnerId merged into GetAgent with -// a oneof identifier (consistent resource lookup). -// - UpsertAgent no longer carries plaintext password. Use -// SetAgentCredentials for password changes. -// - ListAgents supports pagination (page_token) in addition to streaming. -// - ListHuntGroupPauseCodes moved here from GateService (was orphaned). -// - Skill management co-located with agent (assign/unassign are -// agent-scoped operations). - -syntax = "proto3"; - -package tcnapi.exile.agent.v3; - -import "google/api/annotations.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/agent/v3;agentv3"; -option java_multiple_files = true; - -service AgentService { - // Get a single agent by user_id or partner_agent_id. - rpc GetAgent(GetAgentRequest) returns (GetAgentResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}"}; - } - - // List agents with optional filters. Supports both streaming and - // paginated responses. - rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents"}; - } - - // Create or update an agent (excluding credentials). - rpc UpsertAgent(UpsertAgentRequest) returns (UpsertAgentResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}" - body: "*" - }; - } - - // Set agent credentials separately from profile data. - // Keeps passwords out of general CRUD payloads. - rpc SetAgentCredentials(SetAgentCredentialsRequest) returns (SetAgentCredentialsResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/credentials" - body: "*" - }; - } - - // Update agent state (ready, paused, wrapup, etc.). - rpc UpdateAgentStatus(UpdateAgentStatusRequest) returns (UpdateAgentStatusResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/status" - body: "*" - }; - } - - // Get current agent state, session, and connected party. - rpc GetAgentStatus(GetAgentStatusRequest) returns (GetAgentStatusResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/status"}; - } - - rpc MuteAgent(MuteAgentRequest) returns (MuteAgentResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/mute" - body: "*" - }; - } - - rpc UnmuteAgent(UnmuteAgentRequest) returns (UnmuteAgentResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/unmute" - body: "*" - }; - } - - // Record an agent's response to a call (key/value pair). - rpc AddAgentCallResponse(AddAgentCallResponseRequest) returns (AddAgentCallResponseResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/call_responses" - body: "*" - }; - } - - // List pause codes available to an agent's hunt group. - rpc ListHuntGroupPauseCodes(ListHuntGroupPauseCodesRequest) returns (ListHuntGroupPauseCodesResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/pause_codes"}; - } - - // --- Skill management (agent-scoped) --- - - // List all skills defined in the system. - rpc ListSkills(ListSkillsRequest) returns (ListSkillsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/skills"}; - } - - // List skills assigned to a specific agent. - rpc ListAgentSkills(ListAgentSkillsRequest) returns (ListAgentSkillsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills"}; - } - - rpc AssignAgentSkill(AssignAgentSkillRequest) returns (AssignAgentSkillResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills" - body: "*" - }; - } - - rpc UnassignAgentSkill(UnassignAgentSkillRequest) returns (UnassignAgentSkillResponse) { - option (google.api.http) = {delete: "/tcnapi/exile/agent/v3/agents/{partner_agent_id}/skills/{skill_id}"}; - } -} - -// --------------------------------------------------------------------------- -// Request / Response messages -// --------------------------------------------------------------------------- - -message GetAgentRequest { - // Look up by either identifier. - oneof identifier { - string partner_agent_id = 1; - string user_id = 2; - } -} - -message GetAgentResponse { - types.v3.Agent agent = 1; -} - -message ListAgentsRequest { - // Optional filters. - optional bool logged_in = 1; - optional types.v3.AgentState state = 2; - bool include_recording_status = 3; - // Pagination. If empty, returns first page. - string page_token = 4; - int32 page_size = 5; -} - -message ListAgentsResponse { - repeated types.v3.Agent agents = 1; - string next_page_token = 2; -} - -message UpsertAgentRequest { - string partner_agent_id = 1; - string username = 2; - string first_name = 3; - string last_name = 4; -} - -message UpsertAgentResponse { - types.v3.Agent agent = 1; -} - -// Separate RPC for credentials. Keeps passwords out of CRUD payloads -// and audit logs. -message SetAgentCredentialsRequest { - string partner_agent_id = 1; - string password = 2; -} - -message SetAgentCredentialsResponse {} - -message UpdateAgentStatusRequest { - string partner_agent_id = 1; - types.v3.AgentState new_state = 2; - string reason = 3; -} - -message UpdateAgentStatusResponse {} - -message GetAgentStatusRequest { - string partner_agent_id = 1; -} - -message GetAgentStatusResponse { - string partner_agent_id = 1; - types.v3.AgentState agent_state = 2; - string current_session_id = 3; - optional types.v3.Agent.ConnectedParty connected_party = 4; - bool is_muted = 5; - bool is_recording = 6; -} - -message MuteAgentRequest { - string partner_agent_id = 1; -} - -message MuteAgentResponse {} - -message UnmuteAgentRequest { - string partner_agent_id = 1; -} - -message UnmuteAgentResponse {} - -message AddAgentCallResponseRequest { - string partner_agent_id = 1; - int64 call_sid = 2; - types.v3.CallType call_type = 3; - string current_session_id = 4; - string key = 5; - string value = 6; -} - -message AddAgentCallResponseResponse {} - -message ListHuntGroupPauseCodesRequest { - string partner_agent_id = 1; -} - -message ListHuntGroupPauseCodesResponse { - string name = 1; - string description = 2; - repeated PauseCode pause_codes = 3; - - message PauseCode { - string code = 1; - string description = 2; - } -} - -message ListSkillsRequest {} - -message ListSkillsResponse { - repeated types.v3.Skill skills = 1; -} - -message ListAgentSkillsRequest { - string partner_agent_id = 1; -} - -message ListAgentSkillsResponse { - repeated types.v3.Skill skills = 1; -} - -message AssignAgentSkillRequest { - string partner_agent_id = 1; - string skill_id = 2; - int64 proficiency = 3; -} - -message AssignAgentSkillResponse {} - -message UnassignAgentSkillRequest { - string partner_agent_id = 1; - string skill_id = 2; -} - -message UnassignAgentSkillResponse {} diff --git a/core/proto/tcnapi/exile/call/v3/service.proto b/core/proto/tcnapi/exile/call/v3/service.proto deleted file mode 100644 index 67561dd..0000000 --- a/core/proto/tcnapi/exile/call/v3/service.proto +++ /dev/null @@ -1,207 +0,0 @@ -// Call control service. -// -// Extracted from gate/v2 GateService. Handles dialing, transfers, -// hold operations, recording control, and compliance. -// -// Changes from v2: -// - Transfer uses POST (was GET with body in v2 — HTTP violation). -// - Hold operations use a single RPC with action enum instead of -// 6 separate RPCs (PutCallOnSimpleHold, TakeCallOffSimpleHold, -// HoldTransferMember{Caller,Agent}, Unhold...). -// - DialResponse no longer has deprecated caller_sid. -// - NCL ruleset listing co-located here (compliance is call-scoped). - -syntax = "proto3"; - -package tcnapi.exile.call.v3; - -import "google/api/annotations.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/call/v3;callv3"; -option java_multiple_files = true; - -service CallService { - // Initiate an outbound call. - rpc Dial(DialRequest) returns (DialResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/dial" - body: "*" - }; - } - - // Transfer a call. Replaces the GET-with-body Transfer in v2. - rpc Transfer(TransferRequest) returns (TransferResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/transfer" - body: "*" - }; - } - - // Unified hold/unhold. Replaces 6 separate RPCs in v2. - rpc SetHoldState(SetHoldStateRequest) returns (SetHoldStateResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/hold" - body: "*" - }; - } - - rpc StartCallRecording(StartCallRecordingRequest) returns (StartCallRecordingResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/start" - body: "*" - }; - } - - rpc StopCallRecording(StopCallRecordingRequest) returns (StopCallRecordingResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/stop" - body: "*" - }; - } - - rpc GetRecordingStatus(GetRecordingStatusRequest) returns (GetRecordingStatusResponse) { - option (google.api.http) = {get: "/tcnapi/exile/call/v3/agents/{partner_agent_id}/recording/status"}; - } - - // List available compliance ruleset names. - rpc ListComplianceRulesets(ListComplianceRulesetsRequest) returns (ListComplianceRulesetsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/call/v3/compliance/rulesets"}; - } -} - -// --------------------------------------------------------------------------- -// Dial -// --------------------------------------------------------------------------- - -message DialRequest { - string partner_agent_id = 1; - string phone_number = 2; - optional string caller_id = 3; - optional string pool_id = 4; - optional string record_id = 5; - optional string ruleset_name = 6; - optional bool skip_compliance_checks = 7; - optional bool record_call = 8; -} - -message DialResponse { - string phone_number = 1; - string caller_id = 2; - int64 call_sid = 3; - types.v3.CallType call_type = 4; - string org_id = 5; - string partner_agent_id = 6; - bool attempted = 7; - string status = 8; -} - -// --------------------------------------------------------------------------- -// Transfer -// --------------------------------------------------------------------------- - -message TransferRequest { - string partner_agent_id = 1; - - oneof destination { - AgentDestination agent = 2; - OutboundDestination outbound = 3; - QueueDestination queue = 4; - } - - TransferKind kind = 5; - TransferAction action = 6; - - message AgentDestination { - string partner_agent_id = 1; - } - - message OutboundDestination { - string phone_number = 1; - optional string caller_id = 2; - } - - message QueueDestination { - map required_skills = 1; - } - - enum TransferKind { - TRANSFER_KIND_UNSPECIFIED = 0; - TRANSFER_KIND_COLD = 1; - TRANSFER_KIND_WARM = 2; - TRANSFER_KIND_CONFERENCE = 3; - } - - enum TransferAction { - TRANSFER_ACTION_UNSPECIFIED = 0; - TRANSFER_ACTION_START = 1; - TRANSFER_ACTION_APPROVE = 2; - TRANSFER_ACTION_CANCEL = 3; - } -} - -message TransferResponse {} - -// --------------------------------------------------------------------------- -// Hold (unified) -// --------------------------------------------------------------------------- - -message SetHoldStateRequest { - string partner_agent_id = 1; - - HoldTarget target = 2; - HoldAction action = 3; - - // What to hold/unhold. - enum HoldTarget { - HOLD_TARGET_UNSPECIFIED = 0; - // Simple hold on the active call. - HOLD_TARGET_CALL = 1; - // Hold the caller side of a transfer. - HOLD_TARGET_TRANSFER_CALLER = 2; - // Hold the agent side of a transfer. - HOLD_TARGET_TRANSFER_AGENT = 3; - } - - enum HoldAction { - HOLD_ACTION_UNSPECIFIED = 0; - HOLD_ACTION_HOLD = 1; - HOLD_ACTION_UNHOLD = 2; - } -} - -message SetHoldStateResponse {} - -// --------------------------------------------------------------------------- -// Recording -// --------------------------------------------------------------------------- - -message StartCallRecordingRequest { - string partner_agent_id = 1; -} - -message StartCallRecordingResponse {} - -message StopCallRecordingRequest { - string partner_agent_id = 1; -} - -message StopCallRecordingResponse {} - -message GetRecordingStatusRequest { - string partner_agent_id = 1; -} - -message GetRecordingStatusResponse { - bool is_recording = 1; -} - -// --------------------------------------------------------------------------- -// Compliance -// --------------------------------------------------------------------------- - -message ListComplianceRulesetsRequest {} - -message ListComplianceRulesetsResponse { - repeated string ruleset_names = 1; -} diff --git a/core/proto/tcnapi/exile/config/v3/service.proto b/core/proto/tcnapi/exile/config/v3/service.proto deleted file mode 100644 index 6642567..0000000 --- a/core/proto/tcnapi/exile/config/v3/service.proto +++ /dev/null @@ -1,98 +0,0 @@ -// Configuration and lifecycle service. -// -// Extracted from gate/v2 GateService. Handles client configuration, -// organization info, certificate rotation, logging, and journey buffer. - -syntax = "proto3"; - -package tcnapi.exile.config.v3; - -import "google/api/annotations.proto"; -import "google/protobuf/struct.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/config/v3;configv3"; -option java_multiple_files = true; - -service ConfigService { - // Get client configuration. Returns plugin config payload and org info. - // config_payload is now a Struct instead of an opaque string. - rpc GetClientConfiguration(GetClientConfigurationRequest) returns (GetClientConfigurationResponse) { - option (google.api.http) = {get: "/tcnapi/exile/config/v3/client_configuration"}; - } - - rpc GetOrganizationInfo(GetOrganizationInfoRequest) returns (GetOrganizationInfoResponse) { - option (google.api.http) = {get: "/tcnapi/exile/config/v3/organization_info"}; - } - - rpc RotateCertificate(RotateCertificateRequest) returns (RotateCertificateResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/config/v3/rotate_certificate" - body: "*" - }; - } - - // Send log messages to the platform. - rpc Log(LogRequest) returns (LogResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/config/v3/log" - body: "*" - }; - } - - // Add a record to the journey/customer context buffer. - rpc AddRecordToJourneyBuffer(AddRecordToJourneyBufferRequest) returns (AddRecordToJourneyBufferResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/config/v3/journey_buffer" - body: "*" - }; - } -} - -message GetClientConfigurationRequest {} - -message GetClientConfigurationResponse { - string org_id = 1; - string org_name = 2; - string config_name = 3; - // Typed payload instead of opaque string. Consumers deserialize - // into their plugin-specific config record. - google.protobuf.Struct config_payload = 4; -} - -message GetOrganizationInfoRequest {} - -message GetOrganizationInfoResponse { - string org_id = 1; - string org_name = 2; -} - -message RotateCertificateRequest { - string certificate_hash = 1; -} - -message RotateCertificateResponse { - string encoded_certificate = 1; -} - -message LogRequest { - string payload = 1; -} - -message LogResponse {} - -message AddRecordToJourneyBufferRequest { - types.v3.Record record = 1; -} - -message AddRecordToJourneyBufferResponse { - JourneyBufferStatus status = 1; - - enum JourneyBufferStatus { - JOURNEY_BUFFER_STATUS_UNSPECIFIED = 0; - JOURNEY_BUFFER_STATUS_INSERTED = 1; - JOURNEY_BUFFER_STATUS_UPDATED = 2; - JOURNEY_BUFFER_STATUS_IGNORED = 3; - JOURNEY_BUFFER_STATUS_REJECTED = 4; - } -} diff --git a/core/proto/tcnapi/exile/recording/v3/service.proto b/core/proto/tcnapi/exile/recording/v3/service.proto deleted file mode 100644 index a5e8100..0000000 --- a/core/proto/tcnapi/exile/recording/v3/service.proto +++ /dev/null @@ -1,103 +0,0 @@ -// Voice recording search and retrieval service. -// -// Extracted from gate/v2 GateService. -// -// Changes from v2: -// - GetVoiceRecordingDownloadLink changed from GET-with-body to POST. -// - SearchVoiceRecordings supports pagination instead of streaming-only. - -syntax = "proto3"; - -package tcnapi.exile.recording.v3; - -import "google/api/annotations.proto"; -import "google/protobuf/duration.proto"; -import "google/protobuf/timestamp.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/recording/v3;recordingv3"; -option java_multiple_files = true; - -service RecordingService { - rpc SearchVoiceRecordings(SearchVoiceRecordingsRequest) returns (SearchVoiceRecordingsResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/recording/v3/search" - body: "*" - }; - } - - rpc GetDownloadLink(GetDownloadLinkRequest) returns (GetDownloadLinkResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/recording/v3/download_link" - body: "*" - }; - } - - rpc ListSearchableFields(ListSearchableFieldsRequest) returns (ListSearchableFieldsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/recording/v3/searchable_fields"}; - } - - rpc CreateLabel(CreateLabelRequest) returns (CreateLabelResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/recording/v3/labels" - body: "*" - }; - } -} - -// --------------------------------------------------------------------------- -// Messages -// --------------------------------------------------------------------------- - -message SearchVoiceRecordingsRequest { - repeated types.v3.Filter filters = 1; - string page_token = 2; - int32 page_size = 3; -} - -message SearchVoiceRecordingsResponse { - repeated Recording recordings = 1; - string next_page_token = 2; -} - -message Recording { - string recording_id = 1; - int64 call_sid = 2; - types.v3.CallType call_type = 3; - google.protobuf.Duration start_offset = 4; - google.protobuf.Duration end_offset = 5; - google.protobuf.Timestamp start_time = 6; - google.protobuf.Duration duration = 7; - string agent_phone = 8; - string client_phone = 9; - string campaign = 10; - repeated string partner_agent_ids = 11; - string label = 12; - string value = 13; -} - -message GetDownloadLinkRequest { - string recording_id = 1; - optional google.protobuf.Duration start_offset = 2; - optional google.protobuf.Duration end_offset = 3; -} - -message GetDownloadLinkResponse { - string download_link = 1; - string playback_link = 2; -} - -message ListSearchableFieldsRequest {} - -message ListSearchableFieldsResponse { - repeated string fields = 1; -} - -message CreateLabelRequest { - int64 call_sid = 1; - types.v3.CallType call_type = 2; - string key = 3; - string value = 4; -} - -message CreateLabelResponse {} diff --git a/core/proto/tcnapi/exile/scrublist/v3/service.proto b/core/proto/tcnapi/exile/scrublist/v3/service.proto deleted file mode 100644 index f49b01e..0000000 --- a/core/proto/tcnapi/exile/scrublist/v3/service.proto +++ /dev/null @@ -1,68 +0,0 @@ -// Scrub list management service. -// -// Extracted from gate/v2 GateService. - -syntax = "proto3"; - -package tcnapi.exile.scrublist.v3; - -import "google/api/annotations.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/scrublist/v3;scrublistv3"; -option java_multiple_files = true; - -service ScrubListService { - rpc ListScrubLists(ListScrubListsRequest) returns (ListScrubListsResponse) { - option (google.api.http) = {get: "/tcnapi/exile/scrublist/v3/scrublists"}; - } - - rpc AddEntries(AddEntriesRequest) returns (AddEntriesResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries" - body: "*" - }; - } - - rpc UpdateEntry(UpdateEntryRequest) returns (UpdateEntryResponse) { - option (google.api.http) = { - patch: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries/{content}" - body: "*" - }; - } - - rpc RemoveEntries(RemoveEntriesRequest) returns (RemoveEntriesResponse) { - option (google.api.http) = { - post: "/tcnapi/exile/scrublist/v3/scrublists/{scrub_list_id}/entries:remove" - body: "*" - }; - } -} - -message ListScrubListsRequest {} - -message ListScrubListsResponse { - repeated types.v3.ScrubList scrub_lists = 1; -} - -message AddEntriesRequest { - string scrub_list_id = 1; - repeated types.v3.ScrubListEntry entries = 2; - optional string default_country_code = 3; -} - -message AddEntriesResponse {} - -message UpdateEntryRequest { - string scrub_list_id = 1; - types.v3.ScrubListEntry entry = 2; -} - -message UpdateEntryResponse {} - -message RemoveEntriesRequest { - string scrub_list_id = 1; - repeated string entries = 2; -} - -message RemoveEntriesResponse {} diff --git a/core/proto/tcnapi/exile/types/v3/types.proto b/core/proto/tcnapi/exile/types/v3/types.proto deleted file mode 100644 index 94b092d..0000000 --- a/core/proto/tcnapi/exile/types/v3/types.proto +++ /dev/null @@ -1,454 +0,0 @@ -// Shared entity types for the Exile platform. -// -// This package fixes several design issues from core/v2 and gate/v2: -// - Parallel arrays (task_data_keys/values) replaced with repeated TaskData -// - call_type changed from string to CallType enum -// - Filter.Operator unified with SearchOption.Operator -// - Duration fields use google.protobuf.Duration -// - Skill.proficiency marked optional -// - Removed unused AgentEvent/CallerEvent (orphaned in v2 too) -// - Result enum categorized into outcome + detail - -syntax = "proto3"; - -package tcnapi.exile.types.v3; - -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; -import "google/protobuf/timestamp.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/types/v3;typesv3"; -option java_multiple_files = true; - -// --------------------------------------------------------------------------- -// Core data types -// --------------------------------------------------------------------------- - -message Pool { - string pool_id = 1; - string description = 2; - PoolStatus status = 3; - int64 record_count = 4; - - enum PoolStatus { - POOL_STATUS_UNSPECIFIED = 0; - POOL_STATUS_READY = 1; - POOL_STATUS_NOT_READY = 2; - POOL_STATUS_BUSY = 3; - } -} - -message Record { - string pool_id = 1; - string record_id = 2; - // Structured payload instead of raw JSON string. - // Consumers that need raw JSON can use google.protobuf.Struct's JSON mapping. - google.protobuf.Struct payload = 3; -} - -message Field { - string field_name = 1; - string field_value = 2; - string pool_id = 3; - string record_id = 4; -} - -// Replaces the parallel arrays (task_data_keys + task_data_values) antipattern. -// Each entry is a self-contained key-value pair. -message TaskData { - string key = 1; - google.protobuf.Value value = 2; -} - -// Unified filter with full operator set. Replaces both core.v2.Filter -// (which only had EQUAL) and gate.v2.SearchOption (which had a partial set). -message Filter { - string field = 1; - Operator operator = 2; - string value = 3; - - enum Operator { - OPERATOR_UNSPECIFIED = 0; - OPERATOR_EQUAL = 1; - OPERATOR_NOT_EQUAL = 2; - OPERATOR_CONTAINS = 3; - OPERATOR_GREATER_THAN = 4; - OPERATOR_LESS_THAN = 5; - OPERATOR_IN = 6; - OPERATOR_EXISTS = 7; - } -} - -// --------------------------------------------------------------------------- -// Call & telephony enums -// --------------------------------------------------------------------------- - -// Strongly typed call type. Replaces the raw string field in v2 entities. -enum CallType { - CALL_TYPE_UNSPECIFIED = 0; - CALL_TYPE_INBOUND = 1; - CALL_TYPE_OUTBOUND = 2; - CALL_TYPE_PREVIEW = 3; - CALL_TYPE_MANUAL = 4; - CALL_TYPE_MAC = 5; -} - -// Telephony call outcome, split into category + detail for easier handling. -// Replaces the 60+ flat Result enum from v2. -message TelephonyOutcome { - Category category = 1; - Detail detail = 2; - - enum Category { - CATEGORY_UNSPECIFIED = 0; - CATEGORY_ANSWERED = 1; - CATEGORY_NO_ANSWER = 2; - CATEGORY_BUSY = 3; - CATEGORY_MACHINE = 4; - CATEGORY_INVALID = 5; - CATEGORY_FAILED = 6; - CATEGORY_CANCELED = 7; - CATEGORY_PENDING = 8; - } - - // Fine-grained detail within a category. Not all details apply to all - // categories. Consumers should switch on category first, then detail. - enum Detail { - DETAIL_UNSPECIFIED = 0; - DETAIL_GENERIC = 1; - // Answered details - DETAIL_LINKCALL = 2; - DETAIL_LINKCALL_NO_AGENT = 3; - DETAIL_MACHINE_HANGUP = 4; - DETAIL_MACHINE_DELIVERED = 5; - // No-answer details - DETAIL_NO_ANSWER_TIMEOUT = 6; - // Invalid details - DETAIL_INVALID_SIP_INVITE = 7; - DETAIL_INVALID_DESTINATION = 8; - DETAIL_DISCONNECTED_NUMBER = 9; - // Failed details - DETAIL_DNC_CHECK_PERSONAL = 10; - DETAIL_DNC_CHECK_NATIONAL = 11; - DETAIL_COMPLIANCE_BLOCK = 12; - DETAIL_CARRIER_REJECT = 13; - DETAIL_INTERNAL_ERROR = 14; - // Canceled details - DETAIL_AGENT_CANCEL = 15; - DETAIL_SYSTEM_CANCEL = 16; - } -} - -enum TelephonyStatus { - TELEPHONY_STATUS_UNSPECIFIED = 0; - TELEPHONY_STATUS_SCHEDULED = 1; - TELEPHONY_STATUS_RUNNING = 2; - TELEPHONY_STATUS_COMPLETED = 3; -} - -// --------------------------------------------------------------------------- -// Agent & caller state enums -// --------------------------------------------------------------------------- - -enum AgentState { - AGENT_STATE_UNSPECIFIED = 0; - AGENT_STATE_IDLE = 1; - AGENT_STATE_READY = 2; - AGENT_STATE_PEERED = 3; - AGENT_STATE_PAUSED = 4; - AGENT_STATE_WRAPUP = 5; - AGENT_STATE_PREPARING_PREVIEW = 6; - AGENT_STATE_PREVIEWING = 7; - AGENT_STATE_PREPARING_MANUAL = 8; - AGENT_STATE_DIALING = 9; - AGENT_STATE_INTERCOM = 10; - AGENT_STATE_WARM_TRANSFER_PENDING = 11; - AGENT_STATE_WARM_TRANSFER_ACTIVE = 12; - AGENT_STATE_COLD_TRANSFER_ACTIVE = 13; - AGENT_STATE_CONFERENCE_ACTIVE = 14; - AGENT_STATE_LOGGED_OUT = 15; - AGENT_STATE_SUSPENDED = 16; - AGENT_STATE_EXTERNAL_TRANSFER = 17; - AGENT_STATE_CALLBACK_SUSPENDED = 18; -} - -// --------------------------------------------------------------------------- -// Telephony & agent entities (fixed) -// --------------------------------------------------------------------------- - -message AgentCall { - int64 agent_call_sid = 1; - int64 call_sid = 2; - CallType call_type = 3; // was string in v2 - string org_id = 4; - string user_id = 5; - string partner_agent_id = 6; // was field #100 in v2 - string internal_key = 7; - - // Durations as proper Duration messages instead of raw int64. - google.protobuf.Duration talk_duration = 10; - google.protobuf.Duration wait_duration = 11; - google.protobuf.Duration wrapup_duration = 12; - google.protobuf.Duration pause_duration = 13; - google.protobuf.Duration transfer_duration = 14; - google.protobuf.Duration manual_duration = 15; - google.protobuf.Duration preview_duration = 16; - google.protobuf.Duration hold_duration = 17; - google.protobuf.Duration agent_wait_duration = 18; - google.protobuf.Duration suspended_duration = 19; - google.protobuf.Duration external_transfer_duration = 20; - - google.protobuf.Timestamp create_time = 30; - google.protobuf.Timestamp update_time = 31; - - // Replaces parallel task_data_keys/task_data_values arrays. - repeated TaskData task_data = 40; -} - -message TelephonyResult { - int64 call_sid = 1; - CallType call_type = 2; // was string in v2 - string org_id = 3; - string internal_key = 4; - string caller_id = 5; - string phone_number = 6; - string pool_id = 7; - string record_id = 8; - int64 client_sid = 9; - - TelephonyStatus status = 10; - TelephonyOutcome outcome = 11; // was flat 60+ Result enum in v2 - - google.protobuf.Duration delivery_length = 12; - google.protobuf.Duration linkback_length = 13; - - google.protobuf.Timestamp create_time = 20; - google.protobuf.Timestamp update_time = 21; - google.protobuf.Timestamp start_time = 22; - google.protobuf.Timestamp end_time = 23; - - // Replaces parallel task_data_keys/task_data_values arrays. - repeated TaskData task_data = 30; - - // Callback resume tracking. - optional int64 old_call_sid = 40; - optional CallType old_call_type = 41; - optional google.protobuf.Timestamp task_waiting_until = 42; -} - -message AgentResponse { - int64 agent_call_response_sid = 1; - int64 call_sid = 2; - CallType call_type = 3; // was string in v2 - string org_id = 4; - string user_id = 5; - string partner_agent_id = 6; // was field #100 in v2 - string internal_key = 7; - int64 agent_sid = 8; - int64 client_sid = 9; - string response_key = 10; - string response_value = 11; - google.protobuf.Timestamp create_time = 20; - google.protobuf.Timestamp update_time = 21; -} - -message TransferInstance { - int64 client_sid = 1; - string org_id = 2; - int64 transfer_instance_id = 3; - - Source source = 4; - Destination destination = 5; - - TransferType transfer_type = 6; - TransferResult transfer_result = 7; - - // Initiation flags. In v2 these were ambiguous booleans. - TransferInitiation initiation = 8; - - google.protobuf.Timestamp create_time = 10; - google.protobuf.Timestamp transfer_time = 11; - google.protobuf.Timestamp accept_time = 12; - google.protobuf.Timestamp hangup_time = 13; - google.protobuf.Timestamp end_time = 14; - google.protobuf.Timestamp update_time = 15; - - google.protobuf.Duration pending_duration = 20; - google.protobuf.Duration external_duration = 21; - google.protobuf.Duration full_duration = 22; - - // Source of the transfer (the agent initiating). - message Source { - int64 call_sid = 1; - CallType call_type = 2; - string partner_agent_id = 3; - string user_id = 4; - string conversation_id = 5; - int64 session_sid = 6; - int64 agent_call_sid = 7; - } - - // Destination of the transfer. Exactly one must be set. - // Skills apply only to queue-based destinations. - message Destination { - oneof target { - AgentTarget agent = 1; - OutboundTarget outbound = 2; - QueueTarget queue = 3; - } - - message AgentTarget { - int64 session_sid = 1; - string partner_agent_id = 2; - string user_id = 3; - } - - message OutboundTarget { - string phone_number = 1; - int64 call_sid = 2; - CallType call_type = 3; - string conversation_id = 4; - } - - // Queue-based routing with skill requirements. - // Replaces the loose map that sat outside the oneof in v2. - message QueueTarget { - map required_skills = 1; - } - } - - enum TransferType { - TRANSFER_TYPE_UNSPECIFIED = 0; - TRANSFER_TYPE_WARM_AGENT = 1; - TRANSFER_TYPE_WARM_CALLER = 2; - TRANSFER_TYPE_WARM_OUTBOUND = 3; - TRANSFER_TYPE_WARM_SKILL = 4; - TRANSFER_TYPE_COLD_AGENT = 5; - TRANSFER_TYPE_COLD_OUTBOUND = 6; - TRANSFER_TYPE_CONFERENCE = 7; - } - - enum TransferResult { - TRANSFER_RESULT_UNSPECIFIED = 0; - TRANSFER_RESULT_ACCEPTED = 1; - TRANSFER_RESULT_AGENT_CANCEL = 2; - TRANSFER_RESULT_CALLER_HANGUP = 3; - TRANSFER_RESULT_DESTINATION_HANGUP = 4; - } - - // Replaces the two ambiguous boolean flags (start_as_pending, - // started_as_conference) with an explicit enum. - enum TransferInitiation { - TRANSFER_INITIATION_UNSPECIFIED = 0; - TRANSFER_INITIATION_DIRECT = 1; - TRANSFER_INITIATION_PENDING = 2; - TRANSFER_INITIATION_CONFERENCE = 3; - } -} - -message CallRecording { - string recording_id = 1; - string org_id = 2; - int64 call_sid = 3; - CallType call_type = 4; - google.protobuf.Duration duration = 5; - RecordingType recording_type = 6; - google.protobuf.Timestamp start_time = 7; - - enum RecordingType { - RECORDING_TYPE_UNSPECIFIED = 0; - RECORDING_TYPE_TCN = 1; - RECORDING_TYPE_EXTERNAL = 2; - RECORDING_TYPE_VOICEMAIL = 3; - } -} - -message Task { - int64 task_sid = 1; - int64 task_group_sid = 2; - string org_id = 3; - int64 client_sid = 4; - string pool_id = 5; - string record_id = 6; - int64 attempts = 7; - TaskStatus status = 8; - google.protobuf.Timestamp create_time = 10; - google.protobuf.Timestamp update_time = 11; - - enum TaskStatus { - TASK_STATUS_UNSPECIFIED = 0; - TASK_STATUS_SCHEDULED = 1; - TASK_STATUS_WAITING = 2; - TASK_STATUS_PREPARING = 3; - TASK_STATUS_RUNNING = 4; - TASK_STATUS_COMPLETED = 5; - TASK_STATUS_CANCELLED_SYSTEM = 6; - TASK_STATUS_CANCELLED_ADMIN = 7; - } -} - -// --------------------------------------------------------------------------- -// Agent entity -// --------------------------------------------------------------------------- - -message Agent { - string user_id = 1; - string org_id = 2; - string first_name = 3; - string last_name = 4; - string username = 5; - string partner_agent_id = 6; - string current_session_id = 7; - AgentState agent_state = 8; - bool is_logged_in = 9; - bool is_muted = 10; - bool is_recording = 11; - optional ConnectedParty connected_party = 12; - - message ConnectedParty { - int64 call_sid = 1; - CallType call_type = 2; - bool is_inbound = 3; - } -} - -// --------------------------------------------------------------------------- -// Skill -// --------------------------------------------------------------------------- - -message Skill { - string skill_id = 1; - string name = 2; - string description = 3; - // Only set when the skill is assigned to an agent. v2 had this as a - // non-optional int64, making it impossible to distinguish "not assigned" - // from "proficiency = 0". - optional int64 proficiency = 4; -} - -// --------------------------------------------------------------------------- -// Scrub list -// --------------------------------------------------------------------------- - -message ScrubList { - string scrub_list_id = 1; - bool read_only = 2; - ContentType content_type = 3; - - enum ContentType { - CONTENT_TYPE_UNSPECIFIED = 0; - CONTENT_TYPE_PHONE_NUMBER = 1; - CONTENT_TYPE_EMAIL = 2; - CONTENT_TYPE_SMS = 3; - CONTENT_TYPE_ACCOUNT_NUMBER = 4; - CONTENT_TYPE_WHATSAPP = 5; - CONTENT_TYPE_OTHER = 6; - } -} - -message ScrubListEntry { - string content = 1; - optional google.protobuf.Timestamp expiration = 2; - optional string notes = 3; - optional string country_code = 4; -} diff --git a/core/proto/tcnapi/exile/worker/v3/service.proto b/core/proto/tcnapi/exile/worker/v3/service.proto deleted file mode 100644 index bac54cd..0000000 --- a/core/proto/tcnapi/exile/worker/v3/service.proto +++ /dev/null @@ -1,465 +0,0 @@ -// Worker service: unified work stream protocol. -// -// Replaces the three separate connections in gate/v2: -// - JobQueueStream (bidirectional gRPC) — job dispatch + ACK -// - EventStream (bidirectional gRPC) — event dispatch + ACK -// - SubmitJobResults (unary RPC) — result submission -// -// Design principles: -// 1. Single bidirectional stream for all work dispatch and results. -// 2. Client-driven pull with credit-based flow control (backpressure). -// 3. Every work item has a lease/deadline — no hung jobs. -// 4. Results flow on the same stream — no separate RPC. -// 5. Jobs and events are both "work items" — unified dispatch. - -syntax = "proto3"; - -package tcnapi.exile.worker.v3; - -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; -import "google/protobuf/timestamp.proto"; -import "tcnapi/exile/types/v3/types.proto"; - -option go_package = "github.com/tcncloud/exileapi/tcnapi/exile/worker/v3;workerv3"; -option java_multiple_files = true; - -// --------------------------------------------------------------------------- -// Service -// --------------------------------------------------------------------------- - -service WorkerService { - // Single bidirectional stream replacing JobQueueStream, EventStream, - // and SubmitJobResults. - // - // Protocol: - // 1. Client opens stream, sends Register. - // 2. Server responds with Registered (confirms client, sets heartbeat). - // 3. Client sends Pull(max_items=N) to request work. - // 4. Server sends up to N WorkItems, each with a lease deadline. - // 5. Client processes work: - // - For jobs: sends Result with the job output. - // - For events: sends Ack to confirm handling. - // 6. Each Result/Ack frees one unit of capacity. Client sends - // additional Pull to request more work when ready. - // 7. If a lease expires before Result/Ack, server reclaims the - // work item and may dispatch it to another client. - // 8. Client can send ExtendLease to get more time on a work item. - // 9. Both sides send Heartbeat periodically to detect stale connections. - // - // Flow control: - // The server NEVER sends more WorkItems than the client has requested - // via Pull. The client controls its own concurrency by choosing when - // and how many items to pull. This eliminates the need for external - // semaphores or queue executors. - // - // Failure semantics: - // - Stream disconnect: all leased items whose deadlines haven't passed - // remain "in progress" until their lease expires, then get reclaimed. - // - Explicit Nack: client can reject a work item, making it immediately - // available for redelivery. - // - Result delivery is confirmed by ResultAccepted from server, giving - // the client a delivery guarantee (unlike fire-and-forget in v2). - rpc WorkStream(stream WorkRequest) returns (stream WorkResponse); -} - -// --------------------------------------------------------------------------- -// Client -> Server messages -// --------------------------------------------------------------------------- - -message WorkRequest { - oneof action { - Register register = 1; - Pull pull = 2; - Result result = 3; - Ack ack = 4; - Nack nack = 5; - ExtendLease extend_lease = 6; - Heartbeat heartbeat = 7; - } -} - -// First message on stream. Identifies the client and its capabilities. -message Register { - // Human-readable client name (e.g., "sati-finvi-prod-1"). - string client_name = 1; - - // Client software version for diagnostics. - string client_version = 2; - - // Work types this client can handle. Empty means "all types". - // Allows server to route specific work to capable clients. - repeated WorkType capabilities = 3; -} - -// Request work items. Client controls concurrency by choosing max_items. -// -// Sending Pull(max_items=5) means "I can accept 5 more items." -// The server tracks outstanding capacity per client: -// capacity += pull.max_items (on Pull) -// capacity -= 1 (on each WorkItem sent) -// Client typically sends Pull after completing items to replenish. -message Pull { - int32 max_items = 1; -} - -// Submit the result of a completed job. Server confirms with ResultAccepted. -message Result { - string work_id = 1; - - // For large results, send multiple Result messages with the same work_id. - // Set to true on the final message. Server accumulates chunks until this - // is true. The lease remains active during chunked submission. - bool final = 2; - - oneof payload { - ListPoolsResult list_pools = 10; - GetPoolStatusResult get_pool_status = 11; - GetPoolRecordsResult get_pool_records = 12; - SearchRecordsResult search_records = 13; - GetRecordFieldsResult get_record_fields = 14; - SetRecordFieldsResult set_record_fields = 15; - CreatePaymentResult create_payment = 16; - PopAccountResult pop_account = 17; - ExecuteLogicResult execute_logic = 18; - InfoResult info = 19; - ShutdownResult shutdown = 20; - LoggingResult logging = 21; - DiagnosticsResult diagnostics = 22; - ListTenantLogsResult list_tenant_logs = 23; - SetLogLevelResult set_log_level = 24; - ErrorResult error = 30; - } -} - -// Acknowledge processing of an event (no result data needed). -// Can batch multiple IDs for efficiency. -message Ack { - repeated string work_ids = 1; -} - -// Reject a work item. Makes it immediately available for another client. -// Use when the client cannot process the item (e.g., missing capability, -// database down for non-admin work). -message Nack { - string work_id = 1; - string reason = 2; -} - -// Request more time to process a work item. -message ExtendLease { - string work_id = 1; - google.protobuf.Duration extension = 2; -} - -message Heartbeat { - google.protobuf.Timestamp client_time = 1; -} - -// --------------------------------------------------------------------------- -// Server -> Client messages -// --------------------------------------------------------------------------- - -message WorkResponse { - oneof payload { - Registered registered = 1; - WorkItem work_item = 2; - ResultAccepted result_accepted = 3; - LeaseExtended lease_extended = 4; - LeaseExpiring lease_expiring = 5; - Heartbeat heartbeat = 6; - StreamError error = 7; - NackAccepted nack_accepted = 8; - } -} - -// Response to Register. Tells client the heartbeat interval and -// any server-imposed configuration. -message Registered { - string client_id = 1; - google.protobuf.Duration heartbeat_interval = 2; - google.protobuf.Duration default_lease = 3; - // Maximum items the server will dispatch per Pull. Client may request - // more, but server caps at this value. - int32 max_inflight = 4; -} - -// A unit of work dispatched to the client. -message WorkItem { - // Server-assigned unique identifier. - string work_id = 1; - - // Absolute deadline. If the client does not send a Result or Ack - // by this time, the item is reclaimed and eligible for redelivery. - google.protobuf.Timestamp deadline = 2; - - // Whether this item requires a Result (JOB) or just an Ack (EVENT). - WorkCategory category = 3; - - // Delivery attempt number. 1 = first delivery, 2+ = redelivery after - // lease expiry or nack. Clients can use this for deduplication or - // to detect poison-pill items. - int32 attempt = 4; - - oneof task { - // --- Jobs (require Result) --- - ListPoolsTask list_pools = 10; - GetPoolStatusTask get_pool_status = 11; - GetPoolRecordsTask get_pool_records = 12; - SearchRecordsTask search_records = 13; - GetRecordFieldsTask get_record_fields = 14; - SetRecordFieldsTask set_record_fields = 15; - CreatePaymentTask create_payment = 16; - PopAccountTask pop_account = 17; - ExecuteLogicTask execute_logic = 18; - InfoTask info = 19; - ShutdownTask shutdown = 20; - LoggingTask logging = 21; - DiagnosticsTask diagnostics = 22; - ListTenantLogsTask list_tenant_logs = 23; - SetLogLevelTask set_log_level = 24; - - // --- Events (require Ack only) --- - types.v3.AgentCall agent_call = 50; - types.v3.TelephonyResult telephony_result = 51; - types.v3.AgentResponse agent_response = 52; - types.v3.TransferInstance transfer_instance = 53; - types.v3.CallRecording call_recording = 54; - types.v3.Task exile_task = 55; - } -} - -// Confirms the server received and persisted the result. -// This gives clients a delivery guarantee that v2 lacked. -message ResultAccepted { - string work_id = 1; -} - -message LeaseExtended { - string work_id = 1; - google.protobuf.Timestamp new_deadline = 2; -} - -// Warning sent when a lease is about to expire. Gives the client a -// chance to extend or rush completion. -message LeaseExpiring { - string work_id = 1; - google.protobuf.Timestamp deadline = 2; - // Time remaining until expiry. - google.protobuf.Duration remaining = 3; -} - -message NackAccepted { - string work_id = 1; -} - -// Non-fatal stream error (e.g., invalid work_id in a Result). -// Fatal errors close the stream with a gRPC status code. -message StreamError { - string work_id = 1; - string code = 2; - string message = 3; -} - -// --------------------------------------------------------------------------- -// Work categories and types -// --------------------------------------------------------------------------- - -enum WorkCategory { - WORK_CATEGORY_UNSPECIFIED = 0; - // Jobs require a Result response before the deadline. - WORK_CATEGORY_JOB = 1; - // Events require an Ack before the deadline. No result data needed. - WORK_CATEGORY_EVENT = 2; -} - -// Exhaustive list of work types for capability filtering. -enum WorkType { - WORK_TYPE_UNSPECIFIED = 0; - - // Jobs - WORK_TYPE_LIST_POOLS = 1; - WORK_TYPE_GET_POOL_STATUS = 2; - WORK_TYPE_GET_POOL_RECORDS = 3; - WORK_TYPE_SEARCH_RECORDS = 4; - WORK_TYPE_GET_RECORD_FIELDS = 5; - WORK_TYPE_SET_RECORD_FIELDS = 6; - WORK_TYPE_CREATE_PAYMENT = 7; - WORK_TYPE_POP_ACCOUNT = 8; - WORK_TYPE_EXECUTE_LOGIC = 9; - WORK_TYPE_INFO = 10; - WORK_TYPE_SHUTDOWN = 11; - WORK_TYPE_LOGGING = 12; - WORK_TYPE_DIAGNOSTICS = 13; - WORK_TYPE_LIST_TENANT_LOGS = 14; - WORK_TYPE_SET_LOG_LEVEL = 15; - - // Events - WORK_TYPE_AGENT_CALL = 50; - WORK_TYPE_TELEPHONY_RESULT = 51; - WORK_TYPE_AGENT_RESPONSE = 52; - WORK_TYPE_TRANSFER_INSTANCE = 53; - WORK_TYPE_CALL_RECORDING = 54; - WORK_TYPE_TASK = 55; -} - -// --------------------------------------------------------------------------- -// Job task payloads (server -> client) -// --------------------------------------------------------------------------- - -message ListPoolsTask {} - -message GetPoolStatusTask { - string pool_id = 1; -} - -message GetPoolRecordsTask { - string pool_id = 1; - // Page token for continuation. Empty on first request. - string page_token = 2; - int32 page_size = 3; -} - -message SearchRecordsTask { - repeated types.v3.Filter filters = 1; - string page_token = 2; - int32 page_size = 3; -} - -message GetRecordFieldsTask { - string pool_id = 1; - string record_id = 2; - // If empty, return all fields. - repeated string field_names = 3; -} - -message SetRecordFieldsTask { - string pool_id = 1; - string record_id = 2; - repeated types.v3.Field fields = 3; -} - -message CreatePaymentTask { - string pool_id = 1; - string record_id = 2; - google.protobuf.Struct payment_data = 3; -} - -message PopAccountTask { - string pool_id = 1; - string record_id = 2; -} - -message ExecuteLogicTask { - string logic_name = 1; - google.protobuf.Struct parameters = 2; -} - -message InfoTask {} - -message ShutdownTask { - string reason = 1; -} - -message LoggingTask { - string payload = 1; -} - -message DiagnosticsTask {} - -message ListTenantLogsTask { - google.protobuf.Timestamp start_time = 1; - google.protobuf.Timestamp end_time = 2; - string page_token = 3; - int32 page_size = 4; -} - -message SetLogLevelTask { - string logger_name = 1; - string level = 2; -} - -// --------------------------------------------------------------------------- -// Job result payloads (client -> server) -// --------------------------------------------------------------------------- - -message ListPoolsResult { - repeated types.v3.Pool pools = 1; -} - -message GetPoolStatusResult { - types.v3.Pool pool = 1; -} - -message GetPoolRecordsResult { - repeated types.v3.Record records = 1; - // If non-empty, more records are available. Server should dispatch - // a follow-up GetPoolRecordsTask with this token. - string next_page_token = 2; -} - -message SearchRecordsResult { - repeated types.v3.Record records = 1; - string next_page_token = 2; -} - -message GetRecordFieldsResult { - repeated types.v3.Field fields = 1; -} - -message SetRecordFieldsResult { - bool success = 1; -} - -message CreatePaymentResult { - bool success = 1; - string payment_id = 2; -} - -message PopAccountResult { - types.v3.Record record = 1; -} - -message ExecuteLogicResult { - google.protobuf.Struct output = 1; -} - -message InfoResult { - string app_name = 1; - string app_version = 2; - google.protobuf.Struct metadata = 3; -} - -message ShutdownResult {} - -message LoggingResult {} - -// Diagnostics as a generic Struct instead of 50+ Java-specific fields. -// Each runtime (Java, Go, Python) can populate what's relevant. -message DiagnosticsResult { - google.protobuf.Struct system_info = 1; - google.protobuf.Struct runtime_info = 2; - google.protobuf.Struct database_info = 3; - google.protobuf.Struct custom = 4; -} - -message ListTenantLogsResult { - repeated LogEntry entries = 1; - string next_page_token = 2; - - message LogEntry { - google.protobuf.Timestamp timestamp = 1; - string level = 2; - string logger = 3; - string message = 4; - } -} - -message SetLogLevelResult {} - -message ErrorResult { - string message = 1; - // Optional structured error details. - string code = 2; - google.protobuf.Struct details = 3; -} diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index da16f64..a6805e6 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -1,5 +1,6 @@ package com.tcn.exile; +import build.buf.gen.tcnapi.exile.v3.WorkType; import com.tcn.exile.handler.EventHandler; import com.tcn.exile.handler.JobHandler; import com.tcn.exile.internal.ChannelFactory; @@ -11,7 +12,6 @@ import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import tcnapi.exile.worker.v3.WorkType; /** * Main entry point for the Exile client library. diff --git a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java index e13f378..3807512 100644 --- a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java +++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java @@ -47,7 +47,7 @@ public static com.google.protobuf.Timestamp fromInstant(Instant i) { // ---- Enums ---- - public static CallType toCallType(tcnapi.exile.types.v3.CallType ct) { + public static CallType toCallType(build.buf.gen.tcnapi.exile.v3.CallType ct) { return switch (ct) { case CALL_TYPE_INBOUND -> CallType.INBOUND; case CALL_TYPE_OUTBOUND -> CallType.OUTBOUND; @@ -58,7 +58,7 @@ public static CallType toCallType(tcnapi.exile.types.v3.CallType ct) { }; } - public static AgentState toAgentState(tcnapi.exile.types.v3.AgentState as) { + public static AgentState toAgentState(build.buf.gen.tcnapi.exile.v3.AgentState as) { try { return AgentState.valueOf(as.name().replace("AGENT_STATE_", "")); } catch (IllegalArgumentException e) { @@ -66,17 +66,17 @@ public static AgentState toAgentState(tcnapi.exile.types.v3.AgentState as) { } } - public static tcnapi.exile.types.v3.AgentState fromAgentState(AgentState as) { + public static build.buf.gen.tcnapi.exile.v3.AgentState fromAgentState(AgentState as) { try { - return tcnapi.exile.types.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); + return build.buf.gen.tcnapi.exile.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); } catch (IllegalArgumentException e) { - return tcnapi.exile.types.v3.AgentState.AGENT_STATE_UNSPECIFIED; + return build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_UNSPECIFIED; } } // ---- Core types ---- - public static Pool toPool(tcnapi.exile.types.v3.Pool p) { + public static Pool toPool(build.buf.gen.tcnapi.exile.v3.Pool p) { return new Pool( p.getPoolId(), p.getDescription(), @@ -89,39 +89,39 @@ public static Pool toPool(tcnapi.exile.types.v3.Pool p) { p.getRecordCount()); } - public static tcnapi.exile.types.v3.Pool fromPool(Pool p) { - return tcnapi.exile.types.v3.Pool.newBuilder() + public static build.buf.gen.tcnapi.exile.v3.Pool fromPool(Pool p) { + return build.buf.gen.tcnapi.exile.v3.Pool.newBuilder() .setPoolId(p.poolId()) .setDescription(p.description()) .setStatus( switch (p.status()) { - case READY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_READY; - case NOT_READY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY; - case BUSY -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_BUSY; - default -> tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED; + case READY -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_READY; + case NOT_READY -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY; + case BUSY -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_BUSY; + default -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED; }) .setRecordCount(p.recordCount()) .build(); } - public static DataRecord toRecord(tcnapi.exile.types.v3.Record r) { + public static DataRecord toRecord(build.buf.gen.tcnapi.exile.v3.Record r) { return new DataRecord(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload())); } - public static tcnapi.exile.types.v3.Record fromRecord(DataRecord r) { - return tcnapi.exile.types.v3.Record.newBuilder() + public static build.buf.gen.tcnapi.exile.v3.Record fromRecord(DataRecord r) { + return build.buf.gen.tcnapi.exile.v3.Record.newBuilder() .setPoolId(r.poolId()) .setRecordId(r.recordId()) .setPayload(mapToStruct(r.payload())) .build(); } - public static Field toField(tcnapi.exile.types.v3.Field f) { + public static Field toField(build.buf.gen.tcnapi.exile.v3.Field f) { return new Field(f.getFieldName(), f.getFieldValue(), f.getPoolId(), f.getRecordId()); } - public static tcnapi.exile.types.v3.Field fromField(Field f) { - return tcnapi.exile.types.v3.Field.newBuilder() + public static build.buf.gen.tcnapi.exile.v3.Field fromField(Field f) { + return build.buf.gen.tcnapi.exile.v3.Field.newBuilder() .setFieldName(f.fieldName()) .setFieldValue(f.fieldValue()) .setPoolId(f.poolId()) @@ -129,7 +129,7 @@ public static tcnapi.exile.types.v3.Field fromField(Field f) { .build(); } - public static Filter toFilter(tcnapi.exile.types.v3.Filter f) { + public static Filter toFilter(build.buf.gen.tcnapi.exile.v3.Filter f) { return new Filter( f.getField(), switch (f.getOperator()) { @@ -145,25 +145,26 @@ public static Filter toFilter(tcnapi.exile.types.v3.Filter f) { f.getValue()); } - public static tcnapi.exile.types.v3.Filter fromFilter(Filter f) { - return tcnapi.exile.types.v3.Filter.newBuilder() + public static build.buf.gen.tcnapi.exile.v3.Filter fromFilter(Filter f) { + return build.buf.gen.tcnapi.exile.v3.Filter.newBuilder() .setField(f.field()) .setOperator( switch (f.operator()) { - case EQUAL -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_EQUAL; - case NOT_EQUAL -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_NOT_EQUAL; - case CONTAINS -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_CONTAINS; - case GREATER_THAN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_GREATER_THAN; - case LESS_THAN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_LESS_THAN; - case IN -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_IN; - case EXISTS -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_EXISTS; - default -> tcnapi.exile.types.v3.Filter.Operator.OPERATOR_UNSPECIFIED; + case EQUAL -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_EQUAL; + case NOT_EQUAL -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_NOT_EQUAL; + case CONTAINS -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_CONTAINS; + case GREATER_THAN -> + build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_GREATER_THAN; + case LESS_THAN -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_LESS_THAN; + case IN -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_IN; + case EXISTS -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_EXISTS; + default -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_UNSPECIFIED; }) .setValue(f.value()) .build(); } - public static List toTaskData(List list) { + public static List toTaskData(List list) { return list.stream() .map(td -> new TaskData(td.getKey(), valueToObject(td.getValue()))) .collect(Collectors.toList()); @@ -171,7 +172,7 @@ public static List toTaskData(List lis // ---- Agent ---- - public static Agent toAgent(tcnapi.exile.types.v3.Agent a) { + public static Agent toAgent(build.buf.gen.tcnapi.exile.v3.Agent a) { Optional cp = a.hasConnectedParty() ? Optional.of( @@ -195,7 +196,7 @@ public static Agent toAgent(tcnapi.exile.types.v3.Agent a) { cp); } - public static Skill toSkill(tcnapi.exile.types.v3.Skill s) { + public static Skill toSkill(build.buf.gen.tcnapi.exile.v3.Skill s) { return new Skill( s.getSkillId(), s.getName(), @@ -205,7 +206,7 @@ public static Skill toSkill(tcnapi.exile.types.v3.Skill s) { // ---- Events ---- - public static AgentCallEvent toAgentCallEvent(tcnapi.exile.types.v3.AgentCall ac) { + public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.v3.AgentCall ac) { return new AgentCallEvent( ac.getAgentCallSid(), ac.getCallSid(), @@ -231,7 +232,7 @@ public static AgentCallEvent toAgentCallEvent(tcnapi.exile.types.v3.AgentCall ac } public static TelephonyResultEvent toTelephonyResultEvent( - tcnapi.exile.types.v3.TelephonyResult tr) { + build.buf.gen.tcnapi.exile.v3.TelephonyResult tr) { return new TelephonyResultEvent( tr.getCallSid(), toCallType(tr.getCallType()), @@ -254,7 +255,8 @@ public static TelephonyResultEvent toTelephonyResultEvent( toTaskData(tr.getTaskDataList())); } - public static AgentResponseEvent toAgentResponseEvent(tcnapi.exile.types.v3.AgentResponse ar) { + public static AgentResponseEvent toAgentResponseEvent( + build.buf.gen.tcnapi.exile.v3.AgentResponse ar) { return new AgentResponseEvent( ar.getAgentCallResponseSid(), ar.getCallSid(), @@ -271,7 +273,8 @@ public static AgentResponseEvent toAgentResponseEvent(tcnapi.exile.types.v3.Agen toInstant(ar.getUpdateTime())); } - public static CallRecordingEvent toCallRecordingEvent(tcnapi.exile.types.v3.CallRecording cr) { + public static CallRecordingEvent toCallRecordingEvent( + build.buf.gen.tcnapi.exile.v3.CallRecording cr) { return new CallRecordingEvent( cr.getRecordingId(), cr.getOrgId(), @@ -283,7 +286,7 @@ public static CallRecordingEvent toCallRecordingEvent(tcnapi.exile.types.v3.Call } public static TransferInstanceEvent toTransferInstanceEvent( - tcnapi.exile.types.v3.TransferInstance ti) { + build.buf.gen.tcnapi.exile.v3.TransferInstance ti) { var src = ti.getSource(); return new TransferInstanceEvent( ti.getClientSid(), @@ -311,7 +314,7 @@ public static TransferInstanceEvent toTransferInstanceEvent( toDuration(ti.getFullDuration())); } - public static TaskEvent toTaskEvent(tcnapi.exile.types.v3.Task t) { + public static TaskEvent toTaskEvent(build.buf.gen.tcnapi.exile.v3.ExileTask t) { return new TaskEvent( t.getTaskSid(), t.getTaskGroupSid(), diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index a26f86b..37c6683 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -2,6 +2,7 @@ import static com.tcn.exile.internal.ProtoConverter.*; +import build.buf.gen.tcnapi.exile.v3.*; import com.tcn.exile.ExileConfig; import com.tcn.exile.StreamStatus; import com.tcn.exile.StreamStatus.Phase; @@ -21,7 +22,6 @@ import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import tcnapi.exile.worker.v3.*; /** * Implements the v3 WorkStream protocol over a single bidirectional gRPC stream. @@ -284,7 +284,8 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { GetPoolRecordsResult.newBuilder() .addAllRecords( page.items().stream() - .map(r -> ProtoConverter.fromRecord(r)) + .map( + r -> ProtoConverter.fromRecord(r)) .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); } @@ -296,7 +297,8 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { SearchRecordsResult.newBuilder() .addAllRecords( page.items().stream() - .map(r -> ProtoConverter.fromRecord(r)) + .map( + r -> ProtoConverter.fromRecord(r)) .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); } diff --git a/core/src/main/java/com/tcn/exile/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java index 3cf1fdd..e39067a 100644 --- a/core/src/main/java/com/tcn/exile/service/AgentService.java +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -7,25 +7,29 @@ import io.grpc.ManagedChannel; import java.util.List; import java.util.stream.Collectors; -import tcnapi.exile.agent.v3.*; /** Agent management operations. No proto types in the public API. */ public final class AgentService { - private final AgentServiceGrpc.AgentServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.v3.AgentServiceGrpc.AgentServiceBlockingStub stub; AgentService(ManagedChannel channel) { - this.stub = AgentServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.v3.AgentServiceGrpc.newBlockingStub(channel); } public Agent getAgentByPartnerId(String partnerAgentId) { var resp = - stub.getAgent(GetAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + stub.getAgent( + build.buf.gen.tcnapi.exile.v3.GetAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); return toAgent(resp.getAgent()); } public Agent getAgentByUserId(String userId) { - var resp = stub.getAgent(GetAgentRequest.newBuilder().setUserId(userId).build()); + var resp = + stub.getAgent( + build.buf.gen.tcnapi.exile.v3.GetAgentRequest.newBuilder().setUserId(userId).build()); return toAgent(resp.getAgent()); } @@ -36,7 +40,7 @@ public Page listAgents( String pageToken, int pageSize) { var req = - ListAgentsRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.ListAgentsRequest.newBuilder() .setIncludeRecordingStatus(includeRecordingStatus) .setPageSize(pageSize); if (loggedIn != null) req.setLoggedIn(loggedIn); @@ -52,7 +56,7 @@ public Agent upsertAgent( String partnerAgentId, String username, String firstName, String lastName) { var resp = stub.upsertAgent( - UpsertAgentRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.UpsertAgentRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setUsername(username) .setFirstName(firstName) @@ -63,7 +67,7 @@ public Agent upsertAgent( public void setAgentCredentials(String partnerAgentId, String password) { stub.setAgentCredentials( - SetAgentCredentialsRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.SetAgentCredentialsRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setPassword(password) .build()); @@ -71,7 +75,7 @@ public void setAgentCredentials(String partnerAgentId, String password) { public void updateAgentStatus(String partnerAgentId, AgentState newState, String reason) { stub.updateAgentStatus( - UpdateAgentStatusRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.UpdateAgentStatusRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setNewState(fromAgentState(newState)) .setReason(reason != null ? reason : "") @@ -79,11 +83,17 @@ public void updateAgentStatus(String partnerAgentId, AgentState newState, String } public void muteAgent(String partnerAgentId) { - stub.muteAgent(MuteAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + stub.muteAgent( + build.buf.gen.tcnapi.exile.v3.MuteAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); } public void unmuteAgent(String partnerAgentId) { - stub.unmuteAgent(UnmuteAgentRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + stub.unmuteAgent( + build.buf.gen.tcnapi.exile.v3.UnmuteAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); } public void addAgentCallResponse( @@ -94,10 +104,11 @@ public void addAgentCallResponse( String key, String value) { stub.addAgentCallResponse( - AddAgentCallResponseRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.AddAgentCallResponseRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setCallSid(callSid) - .setCallType(tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCallType( + build.buf.gen.tcnapi.exile.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) .setCurrentSessionId(sessionId) .setKey(key) .setValue(value) @@ -105,20 +116,23 @@ public void addAgentCallResponse( } public List listSkills() { - var resp = stub.listSkills(ListSkillsRequest.getDefaultInstance()); + var resp = + stub.listSkills(build.buf.gen.tcnapi.exile.v3.ListSkillsRequest.getDefaultInstance()); return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); } public List listAgentSkills(String partnerAgentId) { var resp = stub.listAgentSkills( - ListAgentSkillsRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + build.buf.gen.tcnapi.exile.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( - AssignAgentSkillRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.AssignAgentSkillRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setSkillId(skillId) .setProficiency(proficiency) @@ -127,7 +141,7 @@ public void assignAgentSkill(String partnerAgentId, String skillId, long profici public void unassignAgentSkill(String partnerAgentId, String skillId) { stub.unassignAgentSkill( - UnassignAgentSkillRequest.newBuilder() + build.buf.gen.tcnapi.exile.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 index bcf8105..9d0a1ec 100644 --- a/core/src/main/java/com/tcn/exile/service/CallService.java +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -3,15 +3,14 @@ import com.tcn.exile.model.CallType; import io.grpc.ManagedChannel; import java.util.Map; -import tcnapi.exile.call.v3.*; /** Call control operations. No proto types in the public API. */ public final class CallService { - private final CallServiceGrpc.CallServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.v3.CallServiceGrpc.CallServiceBlockingStub stub; CallService(ManagedChannel channel) { - this.stub = CallServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.v3.CallServiceGrpc.newBlockingStub(channel); } public record DialResult( @@ -34,7 +33,9 @@ public DialResult dial( Boolean skipCompliance, Boolean recordCall) { var req = - DialRequest.newBuilder().setPartnerAgentId(partnerAgentId).setPhoneNumber(phoneNumber); + build.buf.gen.tcnapi.exile.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); @@ -60,15 +61,27 @@ public void transfer( String destAgentId, String destPhone, Map destSkills) { - var req = TransferRequest.newBuilder().setPartnerAgentId(partnerAgentId); - req.setKind(TransferRequest.TransferKind.valueOf("TRANSFER_KIND_" + kind)); - req.setAction(TransferRequest.TransferAction.valueOf("TRANSFER_ACTION_" + action)); + var req = + build.buf.gen.tcnapi.exile.v3.TransferRequest.newBuilder() + .setPartnerAgentId(partnerAgentId); + req.setKind( + build.buf.gen.tcnapi.exile.v3.TransferRequest.TransferKind.valueOf( + "TRANSFER_KIND_" + kind)); + req.setAction( + build.buf.gen.tcnapi.exile.v3.TransferRequest.TransferAction.valueOf( + "TRANSFER_ACTION_" + action)); if (destAgentId != null) { - req.setAgent(TransferRequest.AgentDestination.newBuilder().setPartnerAgentId(destAgentId)); + req.setAgent( + build.buf.gen.tcnapi.exile.v3.TransferRequest.AgentDestination.newBuilder() + .setPartnerAgentId(destAgentId)); } else if (destPhone != null) { - req.setOutbound(TransferRequest.OutboundDestination.newBuilder().setPhoneNumber(destPhone)); + req.setOutbound( + build.buf.gen.tcnapi.exile.v3.TransferRequest.OutboundDestination.newBuilder() + .setPhoneNumber(destPhone)); } else if (destSkills != null) { - req.setQueue(TransferRequest.QueueDestination.newBuilder().putAllRequiredSkills(destSkills)); + req.setQueue( + build.buf.gen.tcnapi.exile.v3.TransferRequest.QueueDestination.newBuilder() + .putAllRequiredSkills(destSkills)); } stub.transfer(req.build()); } @@ -86,31 +99,42 @@ public enum HoldAction { public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) { stub.setHoldState( - SetHoldStateRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.newBuilder() .setPartnerAgentId(partnerAgentId) - .setTarget(SetHoldStateRequest.HoldTarget.valueOf("HOLD_TARGET_" + target.name())) - .setAction(SetHoldStateRequest.HoldAction.valueOf("HOLD_ACTION_" + action.name())) + .setTarget( + build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.HoldTarget.valueOf( + "HOLD_TARGET_" + target.name())) + .setAction( + build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.HoldAction.valueOf( + "HOLD_ACTION_" + action.name())) .build()); } public void startCallRecording(String partnerAgentId) { stub.startCallRecording( - StartCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + build.buf.gen.tcnapi.exile.v3.StartCallRecordingRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); } public void stopCallRecording(String partnerAgentId) { stub.stopCallRecording( - StopCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); + build.buf.gen.tcnapi.exile.v3.StopCallRecordingRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); } public boolean getRecordingStatus(String partnerAgentId) { return stub.getRecordingStatus( - GetRecordingStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()) + build.buf.gen.tcnapi.exile.v3.GetRecordingStatusRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()) .getIsRecording(); } public java.util.List listComplianceRulesets() { - return stub.listComplianceRulesets(ListComplianceRulesetsRequest.getDefaultInstance()) + return stub.listComplianceRulesets( + build.buf.gen.tcnapi.exile.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 index 976cca3..417bf23 100644 --- a/core/src/main/java/com/tcn/exile/service/ConfigService.java +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -4,15 +4,14 @@ import com.tcn.exile.model.DataRecord; import io.grpc.ManagedChannel; import java.util.Map; -import tcnapi.exile.config.v3.*; /** Configuration and lifecycle operations. No proto types in the public API. */ public final class ConfigService { - private final ConfigServiceGrpc.ConfigServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.v3.ConfigServiceGrpc.ConfigServiceBlockingStub stub; ConfigService(ManagedChannel channel) { - this.stub = ConfigServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.v3.ConfigServiceGrpc.newBlockingStub(channel); } public record ClientConfiguration( @@ -21,7 +20,9 @@ public record ClientConfiguration( public record OrgInfo(String orgId, String orgName) {} public ClientConfiguration getClientConfiguration() { - var resp = stub.getClientConfiguration(GetClientConfigurationRequest.getDefaultInstance()); + var resp = + stub.getClientConfiguration( + build.buf.gen.tcnapi.exile.v3.GetClientConfigurationRequest.getDefaultInstance()); return new ClientConfiguration( resp.getOrgId(), resp.getOrgName(), @@ -30,19 +31,23 @@ public ClientConfiguration getClientConfiguration() { } public OrgInfo getOrganizationInfo() { - var resp = stub.getOrganizationInfo(GetOrganizationInfoRequest.getDefaultInstance()); + var resp = + stub.getOrganizationInfo( + build.buf.gen.tcnapi.exile.v3.GetOrganizationInfoRequest.getDefaultInstance()); return new OrgInfo(resp.getOrgId(), resp.getOrgName()); } public String rotateCertificate(String certificateHash) { var resp = stub.rotateCertificate( - RotateCertificateRequest.newBuilder().setCertificateHash(certificateHash).build()); + build.buf.gen.tcnapi.exile.v3.RotateCertificateRequest.newBuilder() + .setCertificateHash(certificateHash) + .build()); return resp.getEncodedCertificate(); } public void log(String payload) { - stub.log(LogRequest.newBuilder().setPayload(payload).build()); + stub.log(build.buf.gen.tcnapi.exile.v3.LogRequest.newBuilder().setPayload(payload).build()); } public enum JourneyBufferStatus { @@ -56,7 +61,7 @@ public enum JourneyBufferStatus { public JourneyBufferStatus addRecordToJourneyBuffer(DataRecord record) { var resp = stub.addRecordToJourneyBuffer( - AddRecordToJourneyBufferRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.AddRecordToJourneyBufferRequest.newBuilder() .setRecord(ProtoConverter.fromRecord(record)) .build()); return switch (resp.getStatus()) { diff --git a/core/src/main/java/com/tcn/exile/service/RecordingService.java b/core/src/main/java/com/tcn/exile/service/RecordingService.java index f83a1cf..b7813b2 100644 --- a/core/src/main/java/com/tcn/exile/service/RecordingService.java +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -6,15 +6,15 @@ import java.time.Duration; import java.util.List; import java.util.stream.Collectors; -import tcnapi.exile.recording.v3.*; /** Voice recording search and retrieval. No proto types in the public API. */ public final class RecordingService { - private final RecordingServiceGrpc.RecordingServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.v3.RecordingServiceGrpc.RecordingServiceBlockingStub + stub; RecordingService(ManagedChannel channel) { - this.stub = RecordingServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.v3.RecordingServiceGrpc.newBlockingStub(channel); } public record VoiceRecording( @@ -36,7 +36,9 @@ public record DownloadLinks(String downloadLink, String playbackLink) {} public Page searchVoiceRecordings( List filters, String pageToken, int pageSize) { - var req = SearchVoiceRecordingsRequest.newBuilder().setPageSize(pageSize); + var req = + build.buf.gen.tcnapi.exile.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()); @@ -64,7 +66,9 @@ public Page searchVoiceRecordings( public DownloadLinks getDownloadLink( String recordingId, Duration startOffset, Duration endOffset) { - var req = GetDownloadLinkRequest.newBuilder().setRecordingId(recordingId); + var req = + build.buf.gen.tcnapi.exile.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()); @@ -72,15 +76,17 @@ public DownloadLinks getDownloadLink( } public List listSearchableFields() { - return stub.listSearchableFields(ListSearchableFieldsRequest.getDefaultInstance()) + return stub.listSearchableFields( + build.buf.gen.tcnapi.exile.v3.ListSearchableFieldsRequest.getDefaultInstance()) .getFieldsList(); } public void createLabel(long callSid, CallType callType, String key, String value) { stub.createLabel( - CreateLabelRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.CreateLabelRequest.newBuilder() .setCallSid(callSid) - .setCallType(tcnapi.exile.types.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCallType( + build.buf.gen.tcnapi.exile.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 index 5ca8ec0..0920691 100644 --- a/core/src/main/java/com/tcn/exile/service/ScrubListService.java +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -3,15 +3,15 @@ import io.grpc.ManagedChannel; import java.time.Instant; import java.util.List; -import tcnapi.exile.scrublist.v3.*; /** Scrub list management. No proto types in the public API. */ public final class ScrubListService { - private final ScrubListServiceGrpc.ScrubListServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.v3.ScrubListServiceGrpc.ScrubListServiceBlockingStub + stub; ScrubListService(ManagedChannel channel) { - this.stub = ScrubListServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.v3.ScrubListServiceGrpc.newBlockingStub(channel); } public record ScrubList(String scrubListId, boolean readOnly, String contentType) {} @@ -21,7 +21,7 @@ public record ScrubListEntry( public List listScrubLists() { return stub - .listScrubLists(ListScrubListsRequest.getDefaultInstance()) + .listScrubLists(build.buf.gen.tcnapi.exile.v3.ListScrubListsRequest.getDefaultInstance()) .getScrubListsList() .stream() .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), sl.getContentType().name())) @@ -30,10 +30,11 @@ public List listScrubLists() { public void addEntries( String scrubListId, List entries, String defaultCountryCode) { - var req = AddEntriesRequest.newBuilder().setScrubListId(scrubListId); + var req = + build.buf.gen.tcnapi.exile.v3.AddEntriesRequest.newBuilder().setScrubListId(scrubListId); if (defaultCountryCode != null) req.setDefaultCountryCode(defaultCountryCode); for (var e : entries) { - var eb = tcnapi.exile.types.v3.ScrubListEntry.newBuilder().setContent(e.content()); + var eb = build.buf.gen.tcnapi.exile.v3.ScrubListEntry.newBuilder().setContent(e.content()); if (e.expiration() != null) { eb.setExpiration( com.google.protobuf.Timestamp.newBuilder().setSeconds(e.expiration().getEpochSecond())); @@ -46,7 +47,7 @@ public void addEntries( } public void updateEntry(String scrubListId, ScrubListEntry entry) { - var eb = tcnapi.exile.types.v3.ScrubListEntry.newBuilder().setContent(entry.content()); + var eb = build.buf.gen.tcnapi.exile.v3.ScrubListEntry.newBuilder().setContent(entry.content()); if (entry.expiration() != null) { eb.setExpiration( com.google.protobuf.Timestamp.newBuilder() @@ -55,12 +56,15 @@ public void updateEntry(String scrubListId, ScrubListEntry entry) { if (entry.notes() != null) eb.setNotes(entry.notes()); if (entry.countryCode() != null) eb.setCountryCode(entry.countryCode()); stub.updateEntry( - UpdateEntryRequest.newBuilder().setScrubListId(scrubListId).setEntry(eb).build()); + build.buf.gen.tcnapi.exile.v3.UpdateEntryRequest.newBuilder() + .setScrubListId(scrubListId) + .setEntry(eb) + .build()); } public void removeEntries(String scrubListId, List entries) { stub.removeEntries( - RemoveEntriesRequest.newBuilder() + build.buf.gen.tcnapi.exile.v3.RemoveEntriesRequest.newBuilder() .setScrubListId(scrubListId) .addAllEntries(entries) .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 index 071678d..a3747ee 100644 --- a/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java +++ b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java @@ -60,28 +60,29 @@ void instantNullReturnsNull() { void callTypeMapping() { assertEquals( CallType.INBOUND, - ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND)); assertEquals( CallType.OUTBOUND, - ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_OUTBOUND)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_OUTBOUND)); assertEquals( CallType.PREVIEW, - ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_PREVIEW)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_PREVIEW)); assertEquals( CallType.MANUAL, - ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_MANUAL)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_MANUAL)); assertEquals( - CallType.MAC, ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_MAC)); + CallType.MAC, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_MAC)); assertEquals( CallType.UNSPECIFIED, - ProtoConverter.toCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_UNSPECIFIED)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_UNSPECIFIED)); } // ---- AgentState ---- @Test void agentStateRoundTrip() { - var proto = tcnapi.exile.types.v3.AgentState.AGENT_STATE_READY; + var proto = build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_READY; var java = ProtoConverter.toAgentState(proto); assertEquals(AgentState.READY, java); assertEquals(proto, ProtoConverter.fromAgentState(java)); @@ -91,7 +92,8 @@ void agentStateRoundTrip() { void agentStateUnknownReturnsUnspecified() { assertEquals( AgentState.UNSPECIFIED, - ProtoConverter.toAgentState(tcnapi.exile.types.v3.AgentState.AGENT_STATE_UNSPECIFIED)); + ProtoConverter.toAgentState( + build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_UNSPECIFIED)); } // ---- Pool ---- @@ -103,7 +105,8 @@ void poolRoundTrip() { assertEquals("P-1", proto.getPoolId()); assertEquals("Test Pool", proto.getDescription()); assertEquals(42, proto.getRecordCount()); - assertEquals(tcnapi.exile.types.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus()); + assertEquals( + build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus()); var back = ProtoConverter.toPool(proto); assertEquals(java, back); @@ -216,11 +219,11 @@ void valueToObjectHandlesNull() { void taskDataConversion() { var protoList = List.of( - tcnapi.exile.types.v3.TaskData.newBuilder() + build.buf.gen.tcnapi.exile.v3.TaskData.newBuilder() .setKey("pool_id") .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1").build()) .build(), - tcnapi.exile.types.v3.TaskData.newBuilder() + build.buf.gen.tcnapi.exile.v3.TaskData.newBuilder() .setKey("count") .setValue(com.google.protobuf.Value.newBuilder().setNumberValue(5.0).build()) .build()); @@ -237,7 +240,7 @@ void taskDataConversion() { @Test void agentConversion() { var proto = - tcnapi.exile.types.v3.Agent.newBuilder() + build.buf.gen.tcnapi.exile.v3.Agent.newBuilder() .setUserId("U-1") .setOrgId("O-1") .setFirstName("John") @@ -245,14 +248,14 @@ void agentConversion() { .setUsername("jdoe") .setPartnerAgentId("PA-1") .setCurrentSessionId("S-1") - .setAgentState(tcnapi.exile.types.v3.AgentState.AGENT_STATE_READY) + .setAgentState(build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_READY) .setIsLoggedIn(true) .setIsMuted(false) .setIsRecording(true) .setConnectedParty( - tcnapi.exile.types.v3.Agent.ConnectedParty.newBuilder() + build.buf.gen.tcnapi.exile.v3.Agent.ConnectedParty.newBuilder() .setCallSid(42) - .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND) + .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) .setIsInbound(true)) .build(); @@ -272,7 +275,8 @@ void agentConversion() { @Test void agentWithoutConnectedParty() { - var proto = tcnapi.exile.types.v3.Agent.newBuilder().setUserId("U-1").setOrgId("O-1").build(); + var proto = + build.buf.gen.tcnapi.exile.v3.Agent.newBuilder().setUserId("U-1").setOrgId("O-1").build(); var java = ProtoConverter.toAgent(proto); assertTrue(java.connectedParty().isEmpty()); } @@ -282,7 +286,7 @@ void agentWithoutConnectedParty() { @Test void skillWithProficiency() { var proto = - tcnapi.exile.types.v3.Skill.newBuilder() + build.buf.gen.tcnapi.exile.v3.Skill.newBuilder() .setSkillId("SK-1") .setName("Spanish") .setDescription("Spanish language") @@ -300,17 +304,17 @@ void skillWithProficiency() { @Test void agentCallEventConversion() { var proto = - tcnapi.exile.types.v3.AgentCall.newBuilder() + build.buf.gen.tcnapi.exile.v3.AgentCall.newBuilder() .setAgentCallSid(1) .setCallSid(2) - .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_OUTBOUND) + .setCallType(build.buf.gen.tcnapi.exile.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( - tcnapi.exile.types.v3.TaskData.newBuilder() + build.buf.gen.tcnapi.exile.v3.TaskData.newBuilder() .setKey("pool_id") .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1"))) .build(); @@ -330,19 +334,21 @@ void agentCallEventConversion() { @Test void telephonyResultEventConversion() { var proto = - tcnapi.exile.types.v3.TelephonyResult.newBuilder() + build.buf.gen.tcnapi.exile.v3.TelephonyResult.newBuilder() .setCallSid(42) - .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND) + .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) .setOrgId("O-1") .setCallerId("+15551234567") .setPhoneNumber("+15559876543") .setPoolId("P-1") .setRecordId("R-1") - .setStatus(tcnapi.exile.types.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) + .setStatus(build.buf.gen.tcnapi.exile.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) .setOutcome( - tcnapi.exile.types.v3.TelephonyOutcome.newBuilder() - .setCategory(tcnapi.exile.types.v3.TelephonyOutcome.Category.CATEGORY_ANSWERED) - .setDetail(tcnapi.exile.types.v3.TelephonyOutcome.Detail.DETAIL_GENERIC)) + build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.newBuilder() + .setCategory( + build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.Category.CATEGORY_ANSWERED) + .setDetail( + build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.Detail.DETAIL_GENERIC)) .build(); var java = ProtoConverter.toTelephonyResultEvent(proto); @@ -357,13 +363,14 @@ void telephonyResultEventConversion() { @Test void callRecordingEventConversion() { var proto = - tcnapi.exile.types.v3.CallRecording.newBuilder() + build.buf.gen.tcnapi.exile.v3.CallRecording.newBuilder() .setRecordingId("REC-1") .setOrgId("O-1") .setCallSid(42) - .setCallType(tcnapi.exile.types.v3.CallType.CALL_TYPE_INBOUND) + .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) .setDuration(com.google.protobuf.Duration.newBuilder().setSeconds(300)) - .setRecordingType(tcnapi.exile.types.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN) + .setRecordingType( + build.buf.gen.tcnapi.exile.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN) .build(); var java = ProtoConverter.toCallRecordingEvent(proto); @@ -376,14 +383,14 @@ void callRecordingEventConversion() { @Test void taskEventConversion() { var proto = - tcnapi.exile.types.v3.Task.newBuilder() + build.buf.gen.tcnapi.exile.v3.ExileTask.newBuilder() .setTaskSid(1) .setTaskGroupSid(2) .setOrgId("O-1") .setPoolId("P-1") .setRecordId("R-1") .setAttempts(3) - .setStatus(tcnapi.exile.types.v3.Task.TaskStatus.TASK_STATUS_RUNNING) + .setStatus(build.buf.gen.tcnapi.exile.v3.ExileTask.TaskStatus.TASK_STATUS_RUNNING) .build(); var java = ProtoConverter.toTaskEvent(proto); diff --git a/gradle.properties b/gradle.properties index db1c65c..283ea01 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,9 @@ grpcVersion=1.68.1 protobufVersion=4.28.3 +exileapiProtobufVersion=34.1.0.1.20260408145342.461190882f3b +exileapiGrpcVersion=1.80.0.1.20260408145342.461190882f3b + org.gradle.jvmargs=-Xmx4G version=0.0.0-SNAPSHOT From 0f6974fdae867d1dfc5c1cdbf64054756aceb109 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:27:41 -0600 Subject: [PATCH 09/50] =?UTF-8?q?Add=20demo=20module=20=E2=80=94=20plain?= =?UTF-8?q?=20Java=20reference=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal demo showing how to use the sati library. No framework dependencies — plain Java 21 with built-in HttpServer. Components: - Main: bootstraps ExileClientManager with config file watching, starts status HTTP server, registers shutdown hook - DemoJobHandler: implements all JobHandler methods with stub data (fake pools, records, payments) for testing without a real CRM - DemoEventHandler: logs all received events (agent calls, telephony results, transfers, recordings, tasks) - StatusServer: lightweight HTTP server using com.sun.net.httpserver with /health (200/503) and /status (JSON) endpoints Usage: ./gradlew :demo:run # run locally ./gradlew :demo:shadowJar # build fat jar java -jar demo/build/libs/demo-all.jar PORT=9090 CONFIG_DIR=/path/to/config java -jar demo-all.jar Env vars: PORT — HTTP port (default: 8080) CONFIG_DIR — override config directory path Co-Authored-By: Claude Opus 4.6 (1M context) --- demo/build.gradle | 17 +++ .../com/tcn/exile/demo/DemoEventHandler.java | 71 +++++++++ .../com/tcn/exile/demo/DemoJobHandler.java | 137 ++++++++++++++++++ .../main/java/com/tcn/exile/demo/Main.java | 97 +++++++++++++ .../java/com/tcn/exile/demo/StatusServer.java | 135 +++++++++++++++++ demo/src/main/resources/logback.xml | 15 ++ settings.gradle | 2 +- 7 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 demo/build.gradle create mode 100644 demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java create mode 100644 demo/src/main/java/com/tcn/exile/demo/DemoJobHandler.java create mode 100644 demo/src/main/java/com/tcn/exile/demo/Main.java create mode 100644 demo/src/main/java/com/tcn/exile/demo/StatusServer.java create mode 100644 demo/src/main/resources/logback.xml diff --git a/demo/build.gradle b/demo/build.gradle new file mode 100644 index 0000000..72ea56c --- /dev/null +++ b/demo/build.gradle @@ -0,0 +1,17 @@ +plugins { + id("java") + id("application") + id("com.github.johnrengelman.shadow") +} + +application { + mainClass = 'com.tcn.exile.demo.Main' +} + +dependencies { + implementation(project(':core')) + implementation(project(':config')) + + // Logging runtime + runtimeOnly('ch.qos.logback:logback-classic:1.5.17') +} diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java b/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java new file mode 100644 index 0000000..cd94f10 --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java @@ -0,0 +1,71 @@ +package com.tcn.exile.demo; + +import com.tcn.exile.handler.EventHandler; +import com.tcn.exile.model.event.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Logs all received events. In a real integration, these would be persisted to a database. */ +public class DemoEventHandler implements EventHandler { + + private static final Logger log = LoggerFactory.getLogger(DemoEventHandler.class); + + @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/DemoJobHandler.java b/demo/src/main/java/com/tcn/exile/demo/DemoJobHandler.java new file mode 100644 index 0000000..c36990d --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/DemoJobHandler.java @@ -0,0 +1,137 @@ +package com.tcn.exile.demo; + +import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.model.*; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stub job handler that returns fake data for demonstration and testing. Each method logs the + * request and returns plausible dummy responses. + */ +public class DemoJobHandler implements JobHandler { + + private static final Logger log = LoggerFactory.getLogger(DemoJobHandler.class); + + @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); + var records = + 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"))); + return new Page<>(records, ""); + } + + @Override + public Page searchRecords(List filters, String pageToken, int pageSize) { + log.info("searchRecords called with {} filters", filters.size()); + var records = + List.of( + new DataRecord("pool-1", "rec-1", Map.of("name", "Search Result", "matched", true))); + return new Page<>(records, ""); + } + + @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); + } + + @Override + public Map info() { + return Map.of( + "appName", + "sati-demo", + "appVersion", + Main.VERSION, + "runtime", + System.getProperty("java.version"), + "os", + System.getProperty("os.name")); + } + + @Override + public void shutdown(String reason) { + log.warn("Shutdown requested: {}", reason); + // In a real integration, this would trigger graceful shutdown. + } + + @Override + public void processLog(String payload) { + log.info("Remote log: {}", payload); + } + + @Override + public DiagnosticsInfo diagnostics() { + var runtime = Runtime.getRuntime(); + return new DiagnosticsInfo( + Map.of( + "os", System.getProperty("os.name"), + "arch", System.getProperty("os.arch"), + "processors", runtime.availableProcessors()), + Map.of( + "java.version", System.getProperty("java.version"), + "heap.max", runtime.maxMemory(), + "heap.used", runtime.totalMemory() - runtime.freeMemory()), + Map.of("type", "demo", "connected", false), + Map.of("demo", true)); + } + + @Override + public Page listTenantLogs( + Instant startTime, Instant endTime, String pageToken, int pageSize) { + log.info("listTenantLogs called from {} to {}", startTime, endTime); + return new Page<>( + List.of(new LogEntry(Instant.now(), "INFO", "demo", "This is a demo log entry")), ""); + } + + @Override + public void setLogLevel(String loggerName, String level) { + log.info("setLogLevel called: {}={}", loggerName, level); + } +} 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..c56a33b --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/Main.java @@ -0,0 +1,97 @@ +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. + * + *

This is a minimal, plain-Java application that: + * + *

    + *
  • Watches a config file for gate credentials + *
  • Connects to the gate server via the v3 WorkStream protocol + *
  • Handles jobs with stub responses (DemoJobHandler) + *
  • Logs all received events (DemoEventHandler) + *
  • Exposes /health and /status HTTP endpoints + *
+ * + *

Usage: + * + *

+ *   # Place a config file (Base64-encoded JSON with certs):
+ *   echo "$BASE64_CONFIG" > workdir/config/com.tcn.exiles.sati.config.cfg
+ *
+ *   # Run the demo:
+ *   ./gradlew :demo:run
+ *
+ *   # Or with shadow jar:
+ *   java -jar demo/build/libs/demo-all.jar
+ *
+ *   # Check status:
+ *   curl http://localhost:8080/health
+ *   curl http://localhost:8080/status
+ * 
+ */ +public class Main { + + static final String VERSION = "3.0.0-demo"; + private static final Logger log = LoggerFactory.getLogger(Main.class); + + public static void main(String[] args) throws Exception { + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + String configDir = System.getenv().getOrDefault("CONFIG_DIR", ""); + + log.info("Starting sati-demo v{}", VERSION); + + // Build the client manager. It watches for config file changes, + // creates/destroys the ExileClient automatically, and rotates + // certificates before they expire. + var builder = + ExileClientManager.builder() + .clientName("sati-demo") + .clientVersion(VERSION) + .maxConcurrency(5) + .jobHandler(new DemoJobHandler()) + .eventHandler(new DemoEventHandler()) + .onConfigChange( + config -> { + log.info("Config loaded for org={}", config.org()); + // In a real integration, you would initialize your database + // connection pool or HTTP client here. + }); + + if (!configDir.isEmpty()) { + builder.watchDirs(List.of(Path.of(configDir))); + } + + var manager = builder.build(); + + // Start the status HTTP server. + var statusServer = new StatusServer(manager, port); + statusServer.start(); + + // Start watching for config and managing the client. + manager.start(); + + // Register shutdown hook for graceful cleanup. + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + log.info("Shutting down..."); + manager.stop(); + statusServer.close(); + }, + "shutdown")); + + log.info("sati-demo running on port {} — waiting for config file", port); + log.info("Place config at: workdir/config/com.tcn.exiles.sati.config.cfg"); + + // Keep main thread alive. + Thread.currentThread().join(); + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/StatusServer.java b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java new file mode 100644 index 0000000..d53d3da --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java @@ -0,0 +1,135 @@ +package com.tcn.exile.demo; + +import com.sun.net.httpserver.HttpServer; +import com.tcn.exile.StreamStatus; +import com.tcn.exile.config.ExileClientManager; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight HTTP server for health checks and status inspection. Uses Java's built-in HttpServer + * — no framework dependency. + * + *

Endpoints: + * + *

    + *
  • {@code GET /health} — returns "ok" or "unhealthy" + *
  • {@code GET /status} — returns JSON with stream status details + *
+ */ +public class StatusServer implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(StatusServer.class); + + private final HttpServer server; + private final ExileClientManager manager; + + public StatusServer(ExileClientManager manager, int port) throws IOException { + this.manager = manager; + this.server = HttpServer.create(new InetSocketAddress(port), 0); + this.server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + + server.createContext( + "/health", + exchange -> { + var status = manager.streamStatus(); + boolean healthy = status != null && status.isHealthy(); + int code = healthy ? 200 : 503; + var body = healthy ? "ok\n" : "unhealthy\n"; + exchange.sendResponseHeaders(code, body.length()); + try (var os = exchange.getResponseBody()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + }); + + server.createContext( + "/status", + exchange -> { + var status = manager.streamStatus(); + var json = formatStatus(status); + var body = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.createContext( + "/", + exchange -> { + var body = + """ + + + sati-demo + +

sati-demo

+ + + + """ + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + } + + public void start() { + server.start(); + log.info("Status server listening on port {}", server.getAddress().getPort()); + } + + @Override + public void close() { + server.stop(1); + log.info("Status server stopped"); + } + + private String formatStatus(StreamStatus status) { + if (status == null) { + return """ + {"phase":"NO_CLIENT","healthy":false}"""; + } + // Simple JSON without any library. + return String.format( + """ + { + "phase": "%s", + "healthy": %s, + "client_id": %s, + "connected_since": %s, + "last_disconnect": %s, + "last_error": %s, + "inflight": %d, + "completed_total": %d, + "failed_total": %d, + "reconnect_attempts": %d + }""", + status.phase(), + status.isHealthy(), + jsonString(status.clientId()), + jsonString(status.connectedSince()), + jsonString(status.lastDisconnect()), + jsonString(status.lastError()), + status.inflight(), + status.completedTotal(), + status.failedTotal(), + status.reconnectAttempts()); + } + + private static String jsonString(Object value) { + if (value == null) return "null"; + return "\"" + value.toString().replace("\"", "\\\"") + "\""; + } +} diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml new file mode 100644 index 0000000..5293f61 --- /dev/null +++ b/demo/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + + + + + + + + + + + diff --git a/settings.gradle b/settings.gradle index 1db25d6..2991efa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,3 @@ rootProject.name="sati" -include('core', 'config', 'logback-ext') +include('core', 'config', 'logback-ext', 'demo') From 61dfa13341e3079af5ab47ad888c097ec6c6d690 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:39:56 -0600 Subject: [PATCH 10/50] Update proto package from tcnapi.exile.v3 to tcnapi.exile.gate.v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the exileapi rename (tcnapi/exile/v3 → tcnapi/exile/gate/v3). All Java imports updated to build.buf.gen.tcnapi.exile.gate.v3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/tcn/exile/ExileClient.java | 2 +- .../tcn/exile/internal/ProtoConverter.java | 79 ++++++++++--------- .../tcn/exile/internal/WorkStreamClient.java | 6 +- .../com/tcn/exile/service/AgentService.java | 34 ++++---- .../com/tcn/exile/service/CallService.java | 32 ++++---- .../com/tcn/exile/service/ConfigService.java | 15 ++-- .../tcn/exile/service/RecordingService.java | 14 ++-- .../tcn/exile/service/ScrubListService.java | 20 +++-- .../exile/internal/ProtoConverterTest.java | 68 ++++++++-------- 9 files changed, 143 insertions(+), 127 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index a6805e6..adca01e 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -1,6 +1,6 @@ package com.tcn.exile; -import build.buf.gen.tcnapi.exile.v3.WorkType; +import build.buf.gen.tcnapi.exile.gate.v3.WorkType; import com.tcn.exile.handler.EventHandler; import com.tcn.exile.handler.JobHandler; import com.tcn.exile.internal.ChannelFactory; diff --git a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java index 3807512..05cf911 100644 --- a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java +++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java @@ -47,7 +47,7 @@ public static com.google.protobuf.Timestamp fromInstant(Instant i) { // ---- Enums ---- - public static CallType toCallType(build.buf.gen.tcnapi.exile.v3.CallType ct) { + 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; @@ -58,7 +58,7 @@ public static CallType toCallType(build.buf.gen.tcnapi.exile.v3.CallType ct) { }; } - public static AgentState toAgentState(build.buf.gen.tcnapi.exile.v3.AgentState as) { + 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) { @@ -66,17 +66,17 @@ public static AgentState toAgentState(build.buf.gen.tcnapi.exile.v3.AgentState a } } - public static build.buf.gen.tcnapi.exile.v3.AgentState fromAgentState(AgentState as) { + public static build.buf.gen.tcnapi.exile.gate.v3.AgentState fromAgentState(AgentState as) { try { - return build.buf.gen.tcnapi.exile.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); + return build.buf.gen.tcnapi.exile.gate.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); } catch (IllegalArgumentException e) { - return build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_UNSPECIFIED; + return build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED; } } // ---- Core types ---- - public static Pool toPool(build.buf.gen.tcnapi.exile.v3.Pool p) { + public static Pool toPool(build.buf.gen.tcnapi.exile.gate.v3.Pool p) { return new Pool( p.getPoolId(), p.getDescription(), @@ -89,39 +89,40 @@ public static Pool toPool(build.buf.gen.tcnapi.exile.v3.Pool p) { p.getRecordCount()); } - public static build.buf.gen.tcnapi.exile.v3.Pool fromPool(Pool p) { - return build.buf.gen.tcnapi.exile.v3.Pool.newBuilder() + 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.v3.Pool.PoolStatus.POOL_STATUS_READY; - case NOT_READY -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY; - case BUSY -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_BUSY; - default -> build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED; + 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.v3.Record r) { + 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.v3.Record fromRecord(DataRecord r) { - return build.buf.gen.tcnapi.exile.v3.Record.newBuilder() + 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.v3.Field f) { + 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.v3.Field fromField(Field f) { - return build.buf.gen.tcnapi.exile.v3.Field.newBuilder() + 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()) @@ -129,7 +130,7 @@ public static build.buf.gen.tcnapi.exile.v3.Field fromField(Field f) { .build(); } - public static Filter toFilter(build.buf.gen.tcnapi.exile.v3.Filter f) { + public static Filter toFilter(build.buf.gen.tcnapi.exile.gate.v3.Filter f) { return new Filter( f.getField(), switch (f.getOperator()) { @@ -145,26 +146,28 @@ public static Filter toFilter(build.buf.gen.tcnapi.exile.v3.Filter f) { f.getValue()); } - public static build.buf.gen.tcnapi.exile.v3.Filter fromFilter(Filter f) { - return build.buf.gen.tcnapi.exile.v3.Filter.newBuilder() + 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.v3.Filter.Operator.OPERATOR_EQUAL; - case NOT_EQUAL -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_NOT_EQUAL; - case CONTAINS -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_CONTAINS; + 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.v3.Filter.Operator.OPERATOR_GREATER_THAN; - case LESS_THAN -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_LESS_THAN; - case IN -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_IN; - case EXISTS -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_EXISTS; - default -> build.buf.gen.tcnapi.exile.v3.Filter.Operator.OPERATOR_UNSPECIFIED; + 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) { + public static List toTaskData(List list) { return list.stream() .map(td -> new TaskData(td.getKey(), valueToObject(td.getValue()))) .collect(Collectors.toList()); @@ -172,7 +175,7 @@ public static List toTaskData(List cp = a.hasConnectedParty() ? Optional.of( @@ -196,7 +199,7 @@ public static Agent toAgent(build.buf.gen.tcnapi.exile.v3.Agent a) { cp); } - public static Skill toSkill(build.buf.gen.tcnapi.exile.v3.Skill s) { + public static Skill toSkill(build.buf.gen.tcnapi.exile.gate.v3.Skill s) { return new Skill( s.getSkillId(), s.getName(), @@ -206,7 +209,7 @@ public static Skill toSkill(build.buf.gen.tcnapi.exile.v3.Skill s) { // ---- Events ---- - public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.v3.AgentCall ac) { + public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.gate.v3.AgentCall ac) { return new AgentCallEvent( ac.getAgentCallSid(), ac.getCallSid(), @@ -232,7 +235,7 @@ public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.v3.Agen } public static TelephonyResultEvent toTelephonyResultEvent( - build.buf.gen.tcnapi.exile.v3.TelephonyResult tr) { + build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult tr) { return new TelephonyResultEvent( tr.getCallSid(), toCallType(tr.getCallType()), @@ -256,7 +259,7 @@ public static TelephonyResultEvent toTelephonyResultEvent( } public static AgentResponseEvent toAgentResponseEvent( - build.buf.gen.tcnapi.exile.v3.AgentResponse ar) { + build.buf.gen.tcnapi.exile.gate.v3.AgentResponse ar) { return new AgentResponseEvent( ar.getAgentCallResponseSid(), ar.getCallSid(), @@ -274,7 +277,7 @@ public static AgentResponseEvent toAgentResponseEvent( } public static CallRecordingEvent toCallRecordingEvent( - build.buf.gen.tcnapi.exile.v3.CallRecording cr) { + build.buf.gen.tcnapi.exile.gate.v3.CallRecording cr) { return new CallRecordingEvent( cr.getRecordingId(), cr.getOrgId(), @@ -286,7 +289,7 @@ public static CallRecordingEvent toCallRecordingEvent( } public static TransferInstanceEvent toTransferInstanceEvent( - build.buf.gen.tcnapi.exile.v3.TransferInstance ti) { + build.buf.gen.tcnapi.exile.gate.v3.TransferInstance ti) { var src = ti.getSource(); return new TransferInstanceEvent( ti.getClientSid(), @@ -314,7 +317,7 @@ public static TransferInstanceEvent toTransferInstanceEvent( toDuration(ti.getFullDuration())); } - public static TaskEvent toTaskEvent(build.buf.gen.tcnapi.exile.v3.ExileTask t) { + public static TaskEvent toTaskEvent(build.buf.gen.tcnapi.exile.gate.v3.ExileTask t) { return new TaskEvent( t.getTaskSid(), t.getTaskGroupSid(), diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 37c6683..0a4ca58 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -2,7 +2,7 @@ import static com.tcn.exile.internal.ProtoConverter.*; -import build.buf.gen.tcnapi.exile.v3.*; +import build.buf.gen.tcnapi.exile.gate.v3.*; import com.tcn.exile.ExileConfig; import com.tcn.exile.StreamStatus; import com.tcn.exile.StreamStatus.Phase; @@ -284,7 +284,7 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { GetPoolRecordsResult.newBuilder() .addAllRecords( page.items().stream() - .map( + .map( r -> ProtoConverter.fromRecord(r)) .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); @@ -297,7 +297,7 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { SearchRecordsResult.newBuilder() .addAllRecords( page.items().stream() - .map( + .map( r -> ProtoConverter.fromRecord(r)) .toList()) .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); diff --git a/core/src/main/java/com/tcn/exile/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java index e39067a..07bfeef 100644 --- a/core/src/main/java/com/tcn/exile/service/AgentService.java +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -11,16 +11,16 @@ /** Agent management operations. No proto types in the public API. */ public final class AgentService { - private final build.buf.gen.tcnapi.exile.v3.AgentServiceGrpc.AgentServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.gate.v3.AgentServiceGrpc.AgentServiceBlockingStub stub; AgentService(ManagedChannel channel) { - this.stub = build.buf.gen.tcnapi.exile.v3.AgentServiceGrpc.newBlockingStub(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.v3.GetAgentRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()); return toAgent(resp.getAgent()); @@ -29,7 +29,9 @@ public Agent getAgentByPartnerId(String partnerAgentId) { public Agent getAgentByUserId(String userId) { var resp = stub.getAgent( - build.buf.gen.tcnapi.exile.v3.GetAgentRequest.newBuilder().setUserId(userId).build()); + build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder() + .setUserId(userId) + .build()); return toAgent(resp.getAgent()); } @@ -40,7 +42,7 @@ public Page listAgents( String pageToken, int pageSize) { var req = - build.buf.gen.tcnapi.exile.v3.ListAgentsRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.ListAgentsRequest.newBuilder() .setIncludeRecordingStatus(includeRecordingStatus) .setPageSize(pageSize); if (loggedIn != null) req.setLoggedIn(loggedIn); @@ -56,7 +58,7 @@ public Agent upsertAgent( String partnerAgentId, String username, String firstName, String lastName) { var resp = stub.upsertAgent( - build.buf.gen.tcnapi.exile.v3.UpsertAgentRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.UpsertAgentRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setUsername(username) .setFirstName(firstName) @@ -67,7 +69,7 @@ public Agent upsertAgent( public void setAgentCredentials(String partnerAgentId, String password) { stub.setAgentCredentials( - build.buf.gen.tcnapi.exile.v3.SetAgentCredentialsRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.SetAgentCredentialsRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setPassword(password) .build()); @@ -75,7 +77,7 @@ public void setAgentCredentials(String partnerAgentId, String password) { public void updateAgentStatus(String partnerAgentId, AgentState newState, String reason) { stub.updateAgentStatus( - build.buf.gen.tcnapi.exile.v3.UpdateAgentStatusRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.UpdateAgentStatusRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setNewState(fromAgentState(newState)) .setReason(reason != null ? reason : "") @@ -84,14 +86,14 @@ public void updateAgentStatus(String partnerAgentId, AgentState newState, String public void muteAgent(String partnerAgentId) { stub.muteAgent( - build.buf.gen.tcnapi.exile.v3.MuteAgentRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.MuteAgentRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()); } public void unmuteAgent(String partnerAgentId) { stub.unmuteAgent( - build.buf.gen.tcnapi.exile.v3.UnmuteAgentRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.UnmuteAgentRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()); } @@ -104,11 +106,11 @@ public void addAgentCallResponse( String key, String value) { stub.addAgentCallResponse( - build.buf.gen.tcnapi.exile.v3.AddAgentCallResponseRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.AddAgentCallResponseRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setCallSid(callSid) .setCallType( - build.buf.gen.tcnapi.exile.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + build.buf.gen.tcnapi.exile.gate.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) .setCurrentSessionId(sessionId) .setKey(key) .setValue(value) @@ -117,14 +119,14 @@ public void addAgentCallResponse( public List listSkills() { var resp = - stub.listSkills(build.buf.gen.tcnapi.exile.v3.ListSkillsRequest.getDefaultInstance()); + 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.v3.ListAgentSkillsRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.ListAgentSkillsRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()); return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); @@ -132,7 +134,7 @@ public List listAgentSkills(String partnerAgentId) { public void assignAgentSkill(String partnerAgentId, String skillId, long proficiency) { stub.assignAgentSkill( - build.buf.gen.tcnapi.exile.v3.AssignAgentSkillRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.AssignAgentSkillRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setSkillId(skillId) .setProficiency(proficiency) @@ -141,7 +143,7 @@ public void assignAgentSkill(String partnerAgentId, String skillId, long profici public void unassignAgentSkill(String partnerAgentId, String skillId) { stub.unassignAgentSkill( - build.buf.gen.tcnapi.exile.v3.UnassignAgentSkillRequest.newBuilder() + 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 index 9d0a1ec..f663bab 100644 --- a/core/src/main/java/com/tcn/exile/service/CallService.java +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -7,10 +7,10 @@ /** Call control operations. No proto types in the public API. */ public final class CallService { - private final build.buf.gen.tcnapi.exile.v3.CallServiceGrpc.CallServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.CallServiceBlockingStub stub; CallService(ManagedChannel channel) { - this.stub = build.buf.gen.tcnapi.exile.v3.CallServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.newBlockingStub(channel); } public record DialResult( @@ -33,7 +33,7 @@ public DialResult dial( Boolean skipCompliance, Boolean recordCall) { var req = - build.buf.gen.tcnapi.exile.v3.DialRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.DialRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setPhoneNumber(phoneNumber); if (callerId != null) req.setCallerId(callerId); @@ -62,25 +62,25 @@ public void transfer( String destPhone, Map destSkills) { var req = - build.buf.gen.tcnapi.exile.v3.TransferRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.newBuilder() .setPartnerAgentId(partnerAgentId); req.setKind( - build.buf.gen.tcnapi.exile.v3.TransferRequest.TransferKind.valueOf( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferKind.valueOf( "TRANSFER_KIND_" + kind)); req.setAction( - build.buf.gen.tcnapi.exile.v3.TransferRequest.TransferAction.valueOf( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferAction.valueOf( "TRANSFER_ACTION_" + action)); if (destAgentId != null) { req.setAgent( - build.buf.gen.tcnapi.exile.v3.TransferRequest.AgentDestination.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.AgentDestination.newBuilder() .setPartnerAgentId(destAgentId)); } else if (destPhone != null) { req.setOutbound( - build.buf.gen.tcnapi.exile.v3.TransferRequest.OutboundDestination.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.OutboundDestination.newBuilder() .setPhoneNumber(destPhone)); } else if (destSkills != null) { req.setQueue( - build.buf.gen.tcnapi.exile.v3.TransferRequest.QueueDestination.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.QueueDestination.newBuilder() .putAllRequiredSkills(destSkills)); } stub.transfer(req.build()); @@ -99,34 +99,34 @@ public enum HoldAction { public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) { stub.setHoldState( - build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .setTarget( - build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.HoldTarget.valueOf( + build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.HoldTarget.valueOf( "HOLD_TARGET_" + target.name())) .setAction( - build.buf.gen.tcnapi.exile.v3.SetHoldStateRequest.HoldAction.valueOf( + 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.v3.StartCallRecordingRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.StartCallRecordingRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()); } public void stopCallRecording(String partnerAgentId) { stub.stopCallRecording( - build.buf.gen.tcnapi.exile.v3.StopCallRecordingRequest.newBuilder() + 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.v3.GetRecordingStatusRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.GetRecordingStatusRequest.newBuilder() .setPartnerAgentId(partnerAgentId) .build()) .getIsRecording(); @@ -134,7 +134,7 @@ public boolean getRecordingStatus(String partnerAgentId) { public java.util.List listComplianceRulesets() { return stub.listComplianceRulesets( - build.buf.gen.tcnapi.exile.v3.ListComplianceRulesetsRequest.getDefaultInstance()) + 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 index 417bf23..04f7995 100644 --- a/core/src/main/java/com/tcn/exile/service/ConfigService.java +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -8,10 +8,10 @@ /** Configuration and lifecycle operations. No proto types in the public API. */ public final class ConfigService { - private final build.buf.gen.tcnapi.exile.v3.ConfigServiceGrpc.ConfigServiceBlockingStub stub; + private final build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.ConfigServiceBlockingStub stub; ConfigService(ManagedChannel channel) { - this.stub = build.buf.gen.tcnapi.exile.v3.ConfigServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.newBlockingStub(channel); } public record ClientConfiguration( @@ -22,7 +22,7 @@ public record OrgInfo(String orgId, String orgName) {} public ClientConfiguration getClientConfiguration() { var resp = stub.getClientConfiguration( - build.buf.gen.tcnapi.exile.v3.GetClientConfigurationRequest.getDefaultInstance()); + build.buf.gen.tcnapi.exile.gate.v3.GetClientConfigurationRequest.getDefaultInstance()); return new ClientConfiguration( resp.getOrgId(), resp.getOrgName(), @@ -33,21 +33,22 @@ public ClientConfiguration getClientConfiguration() { public OrgInfo getOrganizationInfo() { var resp = stub.getOrganizationInfo( - build.buf.gen.tcnapi.exile.v3.GetOrganizationInfoRequest.getDefaultInstance()); + 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.v3.RotateCertificateRequest.newBuilder() + 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.v3.LogRequest.newBuilder().setPayload(payload).build()); + stub.log( + build.buf.gen.tcnapi.exile.gate.v3.LogRequest.newBuilder().setPayload(payload).build()); } public enum JourneyBufferStatus { @@ -61,7 +62,7 @@ public enum JourneyBufferStatus { public JourneyBufferStatus addRecordToJourneyBuffer(DataRecord record) { var resp = stub.addRecordToJourneyBuffer( - build.buf.gen.tcnapi.exile.v3.AddRecordToJourneyBufferRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.AddRecordToJourneyBufferRequest.newBuilder() .setRecord(ProtoConverter.fromRecord(record)) .build()); return switch (resp.getStatus()) { diff --git a/core/src/main/java/com/tcn/exile/service/RecordingService.java b/core/src/main/java/com/tcn/exile/service/RecordingService.java index b7813b2..b866efb 100644 --- a/core/src/main/java/com/tcn/exile/service/RecordingService.java +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -10,11 +10,11 @@ /** Voice recording search and retrieval. No proto types in the public API. */ public final class RecordingService { - private final build.buf.gen.tcnapi.exile.v3.RecordingServiceGrpc.RecordingServiceBlockingStub + private final build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.RecordingServiceBlockingStub stub; RecordingService(ManagedChannel channel) { - this.stub = build.buf.gen.tcnapi.exile.v3.RecordingServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.newBlockingStub(channel); } public record VoiceRecording( @@ -37,7 +37,7 @@ public record DownloadLinks(String downloadLink, String playbackLink) {} public Page searchVoiceRecordings( List filters, String pageToken, int pageSize) { var req = - build.buf.gen.tcnapi.exile.v3.SearchVoiceRecordingsRequest.newBuilder() + 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)); @@ -67,7 +67,7 @@ public Page searchVoiceRecordings( public DownloadLinks getDownloadLink( String recordingId, Duration startOffset, Duration endOffset) { var req = - build.buf.gen.tcnapi.exile.v3.GetDownloadLinkRequest.newBuilder() + 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)); @@ -77,16 +77,16 @@ public DownloadLinks getDownloadLink( public List listSearchableFields() { return stub.listSearchableFields( - build.buf.gen.tcnapi.exile.v3.ListSearchableFieldsRequest.getDefaultInstance()) + 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.v3.CreateLabelRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.CreateLabelRequest.newBuilder() .setCallSid(callSid) .setCallType( - build.buf.gen.tcnapi.exile.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + 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 index 0920691..e0fc83a 100644 --- a/core/src/main/java/com/tcn/exile/service/ScrubListService.java +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -7,11 +7,11 @@ /** Scrub list management. No proto types in the public API. */ public final class ScrubListService { - private final build.buf.gen.tcnapi.exile.v3.ScrubListServiceGrpc.ScrubListServiceBlockingStub + private final build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.ScrubListServiceBlockingStub stub; ScrubListService(ManagedChannel channel) { - this.stub = build.buf.gen.tcnapi.exile.v3.ScrubListServiceGrpc.newBlockingStub(channel); + this.stub = build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.newBlockingStub(channel); } public record ScrubList(String scrubListId, boolean readOnly, String contentType) {} @@ -21,7 +21,8 @@ public record ScrubListEntry( public List listScrubLists() { return stub - .listScrubLists(build.buf.gen.tcnapi.exile.v3.ListScrubListsRequest.getDefaultInstance()) + .listScrubLists( + build.buf.gen.tcnapi.exile.gate.v3.ListScrubListsRequest.getDefaultInstance()) .getScrubListsList() .stream() .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), sl.getContentType().name())) @@ -31,10 +32,12 @@ public List listScrubLists() { public void addEntries( String scrubListId, List entries, String defaultCountryCode) { var req = - build.buf.gen.tcnapi.exile.v3.AddEntriesRequest.newBuilder().setScrubListId(scrubListId); + 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.v3.ScrubListEntry.newBuilder().setContent(e.content()); + 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())); @@ -47,7 +50,8 @@ public void addEntries( } public void updateEntry(String scrubListId, ScrubListEntry entry) { - var eb = build.buf.gen.tcnapi.exile.v3.ScrubListEntry.newBuilder().setContent(entry.content()); + 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() @@ -56,7 +60,7 @@ public void updateEntry(String scrubListId, ScrubListEntry entry) { if (entry.notes() != null) eb.setNotes(entry.notes()); if (entry.countryCode() != null) eb.setCountryCode(entry.countryCode()); stub.updateEntry( - build.buf.gen.tcnapi.exile.v3.UpdateEntryRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.UpdateEntryRequest.newBuilder() .setScrubListId(scrubListId) .setEntry(eb) .build()); @@ -64,7 +68,7 @@ public void updateEntry(String scrubListId, ScrubListEntry entry) { public void removeEntries(String scrubListId, List entries) { stub.removeEntries( - build.buf.gen.tcnapi.exile.v3.RemoveEntriesRequest.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.RemoveEntriesRequest.newBuilder() .setScrubListId(scrubListId) .addAllEntries(entries) .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 index a3747ee..a9d7f82 100644 --- a/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java +++ b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java @@ -60,29 +60,30 @@ void instantNullReturnsNull() { void callTypeMapping() { assertEquals( CallType.INBOUND, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND)); assertEquals( CallType.OUTBOUND, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_OUTBOUND)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_OUTBOUND)); assertEquals( CallType.PREVIEW, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_PREVIEW)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_PREVIEW)); assertEquals( CallType.MANUAL, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_MANUAL)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MANUAL)); assertEquals( CallType.MAC, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_MAC)); + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MAC)); assertEquals( CallType.UNSPECIFIED, - ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_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.v3.AgentState.AGENT_STATE_READY; + 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)); @@ -93,7 +94,7 @@ void agentStateUnknownReturnsUnspecified() { assertEquals( AgentState.UNSPECIFIED, ProtoConverter.toAgentState( - build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_UNSPECIFIED)); + build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED)); } // ---- Pool ---- @@ -106,7 +107,7 @@ void poolRoundTrip() { assertEquals("Test Pool", proto.getDescription()); assertEquals(42, proto.getRecordCount()); assertEquals( - build.buf.gen.tcnapi.exile.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus()); + build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus()); var back = ProtoConverter.toPool(proto); assertEquals(java, back); @@ -219,11 +220,11 @@ void valueToObjectHandlesNull() { void taskDataConversion() { var protoList = List.of( - build.buf.gen.tcnapi.exile.v3.TaskData.newBuilder() + 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.v3.TaskData.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder() .setKey("count") .setValue(com.google.protobuf.Value.newBuilder().setNumberValue(5.0).build()) .build()); @@ -240,7 +241,7 @@ void taskDataConversion() { @Test void agentConversion() { var proto = - build.buf.gen.tcnapi.exile.v3.Agent.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.Agent.newBuilder() .setUserId("U-1") .setOrgId("O-1") .setFirstName("John") @@ -248,14 +249,14 @@ void agentConversion() { .setUsername("jdoe") .setPartnerAgentId("PA-1") .setCurrentSessionId("S-1") - .setAgentState(build.buf.gen.tcnapi.exile.v3.AgentState.AGENT_STATE_READY) + .setAgentState(build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_READY) .setIsLoggedIn(true) .setIsMuted(false) .setIsRecording(true) .setConnectedParty( - build.buf.gen.tcnapi.exile.v3.Agent.ConnectedParty.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.Agent.ConnectedParty.newBuilder() .setCallSid(42) - .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) + .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND) .setIsInbound(true)) .build(); @@ -276,7 +277,10 @@ void agentConversion() { @Test void agentWithoutConnectedParty() { var proto = - build.buf.gen.tcnapi.exile.v3.Agent.newBuilder().setUserId("U-1").setOrgId("O-1").build(); + 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()); } @@ -286,7 +290,7 @@ void agentWithoutConnectedParty() { @Test void skillWithProficiency() { var proto = - build.buf.gen.tcnapi.exile.v3.Skill.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.Skill.newBuilder() .setSkillId("SK-1") .setName("Spanish") .setDescription("Spanish language") @@ -304,17 +308,17 @@ void skillWithProficiency() { @Test void agentCallEventConversion() { var proto = - build.buf.gen.tcnapi.exile.v3.AgentCall.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.AgentCall.newBuilder() .setAgentCallSid(1) .setCallSid(2) - .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_OUTBOUND) + .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.v3.TaskData.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder() .setKey("pool_id") .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1"))) .build(); @@ -334,21 +338,23 @@ void agentCallEventConversion() { @Test void telephonyResultEventConversion() { var proto = - build.buf.gen.tcnapi.exile.v3.TelephonyResult.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult.newBuilder() .setCallSid(42) - .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) + .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.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) + .setStatus( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) .setOutcome( - build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.newBuilder() .setCategory( - build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.Category.CATEGORY_ANSWERED) + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Category + .CATEGORY_ANSWERED) .setDetail( - build.buf.gen.tcnapi.exile.v3.TelephonyOutcome.Detail.DETAIL_GENERIC)) + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Detail.DETAIL_GENERIC)) .build(); var java = ProtoConverter.toTelephonyResultEvent(proto); @@ -363,14 +369,14 @@ void telephonyResultEventConversion() { @Test void callRecordingEventConversion() { var proto = - build.buf.gen.tcnapi.exile.v3.CallRecording.newBuilder() + build.buf.gen.tcnapi.exile.gate.v3.CallRecording.newBuilder() .setRecordingId("REC-1") .setOrgId("O-1") .setCallSid(42) - .setCallType(build.buf.gen.tcnapi.exile.v3.CallType.CALL_TYPE_INBOUND) + .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.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN) + build.buf.gen.tcnapi.exile.gate.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN) .build(); var java = ProtoConverter.toCallRecordingEvent(proto); @@ -383,14 +389,14 @@ void callRecordingEventConversion() { @Test void taskEventConversion() { var proto = - build.buf.gen.tcnapi.exile.v3.ExileTask.newBuilder() + 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.v3.ExileTask.TaskStatus.TASK_STATUS_RUNNING) + .setStatus(build.buf.gen.tcnapi.exile.gate.v3.ExileTask.TaskStatus.TASK_STATUS_RUNNING) .build(); var java = ProtoConverter.toTaskEvent(proto); From 9513fdcda2e55a794fbff73ffea1a15cc3a1c5bd Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:45:22 -0600 Subject: [PATCH 11/50] Bump exileapi to gate/v3 published artifacts (f723bbfb92f3) --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 283ea01..b5ba5e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ grpcVersion=1.68.1 protobufVersion=4.28.3 -exileapiProtobufVersion=34.1.0.1.20260408145342.461190882f3b -exileapiGrpcVersion=1.80.0.1.20260408145342.461190882f3b +exileapiProtobufVersion=34.1.0.1.20260408174208.f723bbfb92f3 +exileapiGrpcVersion=1.80.0.1.20260408174208.f723bbfb92f3 org.gradle.jvmargs=-Xmx4G From 8e6ec404ad12b7992cc7cc0ad2c6452a38a0924b Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:34:13 -0600 Subject: [PATCH 12/50] Add JourneyService, remove journey RPC from ConfigService Matches exileapi change: AddRecordToJourneyBuffer moved to its own JourneyService. Bumps exileapi to 85794b77f79d. --- .../main/java/com/tcn/exile/ExileClient.java | 6 +++ .../com/tcn/exile/service/ConfigService.java | 24 ------------ .../com/tcn/exile/service/JourneyService.java | 39 +++++++++++++++++++ .../com/tcn/exile/service/ServiceFactory.java | 6 ++- gradle.properties | 4 +- 5 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/service/JourneyService.java diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index adca01e..e53b2b0 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -57,6 +57,7 @@ public final class ExileClient implements AutoCloseable { private final RecordingService recordingService; private final ScrubListService scrubListService; private final ConfigService configService; + private final JourneyService journeyService; private ExileClient(Builder builder) { this.config = builder.config; @@ -79,6 +80,7 @@ private ExileClient(Builder builder) { this.recordingService = services.recording(); this.scrubListService = services.scrubList(); this.configService = services.config(); + this.journeyService = services.journey(); } /** Start the work stream. Call this after building the client. */ @@ -116,6 +118,10 @@ public ConfigService config_() { return configService; } + public JourneyService journey() { + return journeyService; + } + @Override public void close() { log.info("Shutting down ExileClient"); diff --git a/core/src/main/java/com/tcn/exile/service/ConfigService.java b/core/src/main/java/com/tcn/exile/service/ConfigService.java index 04f7995..c0b8a2c 100644 --- a/core/src/main/java/com/tcn/exile/service/ConfigService.java +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -1,7 +1,6 @@ package com.tcn.exile.service; import com.tcn.exile.internal.ProtoConverter; -import com.tcn.exile.model.DataRecord; import io.grpc.ManagedChannel; import java.util.Map; @@ -50,27 +49,4 @@ public void log(String payload) { stub.log( build.buf.gen.tcnapi.exile.gate.v3.LogRequest.newBuilder().setPayload(payload).build()); } - - 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/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/ServiceFactory.java b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java index 9e1298a..0d6e322 100644 --- a/core/src/main/java/com/tcn/exile/service/ServiceFactory.java +++ b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java @@ -11,7 +11,8 @@ public record Services( CallService call, RecordingService recording, ScrubListService scrubList, - ConfigService config) {} + ConfigService config, + JourneyService journey) {} public static Services create(ManagedChannel channel) { return new Services( @@ -19,6 +20,7 @@ public static Services create(ManagedChannel channel) { new CallService(channel), new RecordingService(channel), new ScrubListService(channel), - new ConfigService(channel)); + new ConfigService(channel), + new JourneyService(channel)); } } diff --git a/gradle.properties b/gradle.properties index b5ba5e8..f496c5d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ grpcVersion=1.68.1 protobufVersion=4.28.3 -exileapiProtobufVersion=34.1.0.1.20260408174208.f723bbfb92f3 -exileapiGrpcVersion=1.80.0.1.20260408174208.f723bbfb92f3 +exileapiProtobufVersion=34.1.0.1.20260408183143.85794b77f79d +exileapiGrpcVersion=1.80.0.1.20260408183143.85794b77f79d org.gradle.jvmargs=-Xmx4G From c969ed77ad0455769b5b3c9ecf811597615e028d Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:51:30 -0600 Subject: [PATCH 13/50] Add config polling to ExileClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polls GetClientConfiguration from the gate every 10 seconds (configurable). Fires onConfigPolled callback when config changes. This replaces the v2 GateClientConfiguration polling loop. New Builder methods: - onConfigPolled(Consumer) — callback for config changes - configPollInterval(Duration) — polling interval (default 10s) New accessor: - lastPolledConfig() — returns the last config received from gate --- .../main/java/com/tcn/exile/ExileClient.java | 119 ++++++++++-------- 1 file changed, 69 insertions(+), 50 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index e53b2b0..73ee24c 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -1,15 +1,19 @@ package com.tcn.exile; -import build.buf.gen.tcnapi.exile.gate.v3.WorkType; import com.tcn.exile.handler.EventHandler; import com.tcn.exile.handler.JobHandler; import com.tcn.exile.internal.ChannelFactory; import com.tcn.exile.internal.WorkStreamClient; import com.tcn.exile.service.*; import io.grpc.ManagedChannel; +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.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,29 +21,7 @@ * Main entry point for the Exile client library. * *

Connects to the gate server, opens a work stream, and exposes domain service clients for - * making unary RPCs. - * - *

Usage: - * - *

{@code
- * var client = ExileClient.builder()
- *     .config(exileConfig)
- *     .clientName("sati-finvi-prod-1")
- *     .clientVersion("3.0.0")
- *     .maxConcurrency(5)
- *     .jobHandler(myJobHandler)
- *     .eventHandler(myEventHandler)
- *     .build();
- *
- * client.start();
- *
- * // Use domain services for unary RPCs.
- * var agents = client.agents().listAgents(ListAgentsRequest.getDefaultInstance());
- * var status = client.calls().getRecordingStatus(req);
- *
- * // When done:
- * client.close();
- * }
+ * making unary RPCs. Periodically polls the gate for configuration updates. */ public final class ExileClient implements AutoCloseable { @@ -47,11 +29,11 @@ public final class ExileClient implements AutoCloseable { private final ExileConfig config; private final WorkStreamClient workStream; - - // Shared channel for unary RPCs (separate from the stream channel). private final ManagedChannel serviceChannel; + private final ScheduledExecutorService configPoller; + private final Consumer onConfigPolled; + private final Duration configPollInterval; - // Domain service clients. private final AgentService agentService; private final CallService callService; private final RecordingService recordingService; @@ -59,8 +41,12 @@ public final class ExileClient implements AutoCloseable { private final ConfigService configService; private final JourneyService journeyService; + private volatile ConfigService.ClientConfiguration lastConfig; + private ExileClient(Builder builder) { this.config = builder.config; + this.onConfigPolled = builder.onConfigPolled; + this.configPollInterval = builder.configPollInterval; this.workStream = new WorkStreamClient( @@ -72,7 +58,6 @@ private ExileClient(Builder builder) { builder.maxConcurrency, builder.capabilities); - // Create a shared channel for unary RPCs. this.serviceChannel = ChannelFactory.create(config); var services = ServiceFactory.create(serviceChannel); this.agentService = services.agent(); @@ -81,15 +66,53 @@ private ExileClient(Builder builder) { this.scrubListService = services.scrubList(); this.configService = services.config(); this.journeyService = services.journey(); + + this.configPoller = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "exile-config-poller"); + t.setDaemon(true); + return t; + }); } - /** Start the work stream. Call this after building the client. */ + /** Start the work stream and config poller. */ public void start() { log.info("Starting ExileClient for org={}", config.org()); workStream.start(); + + configPoller.scheduleAtFixedRate( + this::pollConfig, + configPollInterval.toSeconds(), + 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 && onConfigPolled != null) { + log.info( + "Config polled from gate (org={}, configName={}, changed=true)", + newConfig.orgId(), + newConfig.configName()); + onConfigPolled.accept(newConfig); + } + } catch (Exception e) { + log.debug("Config poll failed (gate may not be reachable yet): {}", e.getMessage()); + } + } + + /** Returns the last polled config from the gate, or null if never polled. */ + public ConfigService.ClientConfiguration lastPolledConfig() { + return lastConfig; } - /** Returns a snapshot of the work stream's current state. */ public StreamStatus streamStatus() { return workStream.status(); } @@ -125,6 +148,7 @@ public JourneyService journey() { @Override public void close() { log.info("Shutting down ExileClient"); + configPoller.shutdownNow(); workStream.close(); ChannelFactory.shutdown(serviceChannel); } @@ -140,50 +164,37 @@ public static final class Builder { private String clientName = "sati"; private String clientVersion = "unknown"; private int maxConcurrency = 5; - private List capabilities = new ArrayList<>(); + private List capabilities = new ArrayList<>(); + private Consumer onConfigPolled; + private Duration configPollInterval = Duration.ofSeconds(10); private Builder() {} - /** Required. Connection configuration (certs, endpoint). */ public Builder config(ExileConfig config) { this.config = Objects.requireNonNull(config); return this; } - /** - * Job handler implementation. Defaults to a no-op handler that rejects all jobs with - * UnsupportedOperationException. - */ public Builder jobHandler(JobHandler jobHandler) { this.jobHandler = Objects.requireNonNull(jobHandler); return this; } - /** - * Event handler implementation. Defaults to a no-op handler that acknowledges all events - * without processing. - */ public Builder eventHandler(EventHandler eventHandler) { this.eventHandler = Objects.requireNonNull(eventHandler); return this; } - /** Human-readable client name for diagnostics. */ public Builder clientName(String clientName) { this.clientName = Objects.requireNonNull(clientName); return this; } - /** Client software version for diagnostics. */ public Builder clientVersion(String clientVersion) { this.clientVersion = Objects.requireNonNull(clientVersion); return this; } - /** - * Maximum number of work items to process concurrently. Controls the Pull(max_items) sent to - * the server. Default: 5. - */ public Builder maxConcurrency(int maxConcurrency) { if (maxConcurrency < 1) throw new IllegalArgumentException("maxConcurrency must be >= 1"); this.maxConcurrency = maxConcurrency; @@ -191,14 +202,22 @@ public Builder maxConcurrency(int maxConcurrency) { } /** - * Work types this client can handle. Empty (default) means all types. Use this to limit what - * the server dispatches to this client. + * Callback invoked when the gate returns a new or changed client configuration. The config + * payload typically contains database credentials, API keys, or plugin-specific settings. This + * is polled from the gate every {@code configPollInterval}. */ - public Builder capabilities(List capabilities) { - this.capabilities = new ArrayList<>(Objects.requireNonNull(capabilities)); + public Builder onConfigPolled(Consumer onConfigPolled) { + this.onConfigPolled = onConfigPolled; + 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; } + @SuppressWarnings("unchecked") public ExileClient build() { Objects.requireNonNull(config, "config is required"); return new ExileClient(this); From ccbd74a205d3838dfb313fd75413d2b3704488b2 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:00:31 -0600 Subject: [PATCH 14/50] Improve debug logging for connection lifecycle - WorkStreamClient: log endpoint, attempt number, backoff delay, exception class name on disconnect/error - ExileClientManager: log endpoint and org when creating client - ExileClient: log exception class on config poll failure - Channel creation: debug log before/after channel setup --- .../tcn/exile/config/ExileClientManager.java | 6 +++++- .../main/java/com/tcn/exile/ExileClient.java | 2 +- .../tcn/exile/internal/WorkStreamClient.java | 18 ++++++++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java index 3befbfe..60d049b 100644 --- a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -146,7 +146,11 @@ public void close() { } private void createClient(ExileConfig config) { - // If org changed, destroy the old client first. + log.info( + "Creating ExileClient (endpoint={}:{}, org={})", + config.apiHostname(), + config.apiPort(), + config.org()); var newOrg = config.org(); if (activeOrg != null && !activeOrg.equals(newOrg)) { log.info("Org changed from {} to {}, destroying old client", activeOrg, newOrg); diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 73ee24c..46d512a 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -104,7 +104,7 @@ private void pollConfig() { onConfigPolled.accept(newConfig); } } catch (Exception e) { - log.debug("Config poll failed (gate may not be reachable yet): {}", e.getMessage()); + log.debug("Config poll failed ({}): {}", e.getClass().getSimpleName(), e.getMessage()); } } diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 0a4ca58..d205d85 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -103,11 +103,15 @@ private void reconnectLoop() { var backoff = new Backoff(); while (running.get()) { try { - if (backoff.nextDelayMs() > 0) { + long delayMs = backoff.nextDelayMs(); + if (delayMs > 0) { phase = Phase.RECONNECTING; + log.info("Reconnecting in {}ms (attempt #{})", delayMs, reconnectAttempts.get() + 1); } backoff.sleep(); - reconnectAttempts.incrementAndGet(); + long attempt = reconnectAttempts.incrementAndGet(); + log.info( + "Connecting to {}:{} (attempt #{})", config.apiHostname(), config.apiPort(), attempt); runStream(); backoff.reset(); } catch (InterruptedException e) { @@ -116,10 +120,10 @@ private void reconnectLoop() { } catch (Exception e) { backoff.recordFailure(); lastDisconnect = Instant.now(); - lastError = e.getMessage(); + lastError = e.getClass().getSimpleName() + ": " + e.getMessage(); connectedSince = null; clientId = null; - log.warn("Stream disconnected: {}", e.getMessage()); + log.warn("Stream disconnected ({}): {}", e.getClass().getSimpleName(), e.getMessage()); } } phase = Phase.CLOSED; @@ -128,7 +132,9 @@ private void reconnectLoop() { private void runStream() throws InterruptedException { phase = Phase.CONNECTING; + log.debug("Creating gRPC channel to {}:{}", config.apiHostname(), config.apiPort()); channel = ChannelFactory.create(config); + log.debug("Channel created, opening WorkStream"); try { var stub = WorkerServiceGrpc.newStub(channel); var latch = new CountDownLatch(1); @@ -143,10 +149,10 @@ public void onNext(WorkResponse response) { @Override public void onError(Throwable t) { - lastError = t.getMessage(); + lastError = t.getClass().getSimpleName() + ": " + t.getMessage(); lastDisconnect = Instant.now(); connectedSince = null; - log.warn("Stream error: {}", t.getMessage()); + log.warn("Stream error ({}): {}", t.getClass().getSimpleName(), t.getMessage()); latch.countDown(); } From 72ac3ba717fb36116d36235e1f73003a700f0929 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:11:59 -0600 Subject: [PATCH 15/50] Fix config parsing, PKCS#1 keys, gRPC channel creation - ConfigParser: handle URL-format api_endpoint (https://host) by stripping scheme and extracting host:port - ChannelFactory: convert PKCS#1 (BEGIN RSA PRIVATE KEY) to PKCS#8 using BouncyCastle ASN.1 parser; use InetSocketAddress to avoid unix domain socket name resolver on macOS - Bump grpc to 1.80.0 (matches buf-generated stub transitive dep) - Bump bouncycastle to 1.79 - Demo shadowJar: add mergeServiceFiles() for gRPC service loader --- .../com/tcn/exile/config/ConfigParser.java | 26 ++++++++-- core/build.gradle | 3 ++ .../tcn/exile/internal/ChannelFactory.java | 52 +++++++++++++++++-- demo/build.gradle | 4 ++ gradle.properties | 2 +- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/config/src/main/java/com/tcn/exile/config/ConfigParser.java b/config/src/main/java/com/tcn/exile/config/ConfigParser.java index 69687aa..7bd46a9 100644 --- a/config/src/main/java/com/tcn/exile/config/ConfigParser.java +++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java @@ -64,11 +64,29 @@ public static Optional parse(byte[] raw) { ExileConfig.builder().rootCert(rootCert).publicCert(publicCert).privateKey(privateKey); if (endpoint != null && !endpoint.isEmpty()) { - var parts = endpoint.split(":"); - builder.apiHostname(parts[0]); - if (parts.length > 1) { - builder.apiPort(Integer.parseInt(parts[1])); + // 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()); diff --git a/core/build.gradle b/core/build.gradle index a7dfa6c..256929b 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -21,6 +21,9 @@ dependencies { // protobuf runtime api("com.google.protobuf:protobuf-java:${protobufVersion}") + // PKCS#1 → PKCS#8 key conversion for mTLS + implementation("org.bouncycastle:bcpkix-jdk18on:1.79") + // javax.annotation for @Generated in gRPC stubs compileOnly("org.apache.tomcat:annotations-api:6.0.53") diff --git a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java index 638a3a6..1ae2bfd 100644 --- a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java +++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java @@ -4,9 +4,13 @@ import io.grpc.ManagedChannel; import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; -import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; 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}. */ @@ -17,17 +21,24 @@ private ChannelFactory() {} /** Create a new mTLS channel to the gate server. Caller owns the channel lifecycle. */ public static ManagedChannel create(ExileConfig config) { try { - SslContext sslContext = + // 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(config.privateKey().getBytes(StandardCharsets.UTF_8))) + new ByteArrayInputStream(keyPem.getBytes(StandardCharsets.UTF_8))) .build(); - return NettyChannelBuilder.forAddress(config.apiHostname(), config.apiPort()) - .overrideAuthority("exile-proxy") + // Use InetSocketAddress to avoid the unix domain socket name resolver on macOS. + return NettyChannelBuilder.forAddress( + new InetSocketAddress(config.apiHostname(), config.apiPort())) .sslContext(sslContext) .keepAliveTime(32, TimeUnit.SECONDS) .keepAliveTimeout(30, TimeUnit.SECONDS) @@ -53,4 +64,35 @@ public static void shutdown(ManagedChannel channel) { 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/demo/build.gradle b/demo/build.gradle index 72ea56c..58b96ad 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -8,6 +8,10 @@ application { mainClass = 'com.tcn.exile.demo.Main' } +shadowJar { + mergeServiceFiles() +} + dependencies { implementation(project(':core')) implementation(project(':config')) diff --git a/gradle.properties b/gradle.properties index f496c5d..dc17e65 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -grpcVersion=1.68.1 +grpcVersion=1.80.0 protobufVersion=4.28.3 exileapiProtobufVersion=34.1.0.1.20260408183143.85794b77f79d From a4707a9aa9f8be9c86c43567151cfe799eda7cad Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:21:13 -0600 Subject: [PATCH 16/50] Gate WorkStream until first successful config poll start() now only begins the config poller. The WorkStream opens after the first successful GetClientConfiguration response from the gate and the onConfigPolled callback completes without error. This ensures the integration's resources (database, HTTP client) are initialized before any work items arrive. If the gate is unreachable or the cert is invalid, the client stays in IDLE phase without wasting reconnect attempts on the WorkStream. --- .../main/java/com/tcn/exile/ExileClient.java | 32 +++++++++++++------ .../tcn/exile/internal/WorkStreamClient.java | 7 ++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 46d512a..b1ee8af 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -13,6 +13,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,8 +21,10 @@ /** * Main entry point for the Exile client library. * - *

Connects to the gate server, opens a work stream, and exposes domain service clients for - * making unary RPCs. Periodically polls the gate for configuration updates. + *

On {@link #start()}, only the config poller begins. The WorkStream does not open until the + * first successful config poll from the gate and the {@code onConfigPolled} callback completes + * without error. This ensures the integration's resources (database, HTTP client) are initialized + * before any work items arrive. */ public final class ExileClient implements AutoCloseable { @@ -42,6 +45,7 @@ public final class ExileClient implements AutoCloseable { private final JourneyService journeyService; private volatile ConfigService.ClientConfiguration lastConfig; + private final AtomicBoolean workStreamStarted = new AtomicBoolean(false); private ExileClient(Builder builder) { this.config = builder.config; @@ -76,14 +80,18 @@ private ExileClient(Builder builder) { }); } - /** Start the work stream and config poller. */ + /** + * Start the client. Only the config poller begins immediately. The WorkStream opens after the + * first successful config poll and onConfigPolled callback. + */ public void start() { - log.info("Starting ExileClient for org={}", config.org()); - workStream.start(); + log.info( + "Starting ExileClient for org={} (waiting for gate config before opening stream)", + config.org()); configPoller.scheduleAtFixedRate( this::pollConfig, - configPollInterval.toSeconds(), + 0, // poll immediately on start configPollInterval.toSeconds(), TimeUnit.SECONDS); } @@ -103,6 +111,12 @@ private void pollConfig() { newConfig.configName()); onConfigPolled.accept(newConfig); } + + // Start WorkStream on first successful config poll. + if (workStreamStarted.compareAndSet(false, true)) { + log.info("Gate config received, starting WorkStream"); + workStream.start(); + } } catch (Exception e) { log.debug("Config poll failed ({}): {}", e.getClass().getSimpleName(), e.getMessage()); } @@ -202,9 +216,9 @@ public Builder maxConcurrency(int maxConcurrency) { } /** - * Callback invoked when the gate returns a new or changed client configuration. The config - * payload typically contains database credentials, API keys, or plugin-specific settings. This - * is polled from the gate every {@code configPollInterval}. + * Callback invoked when the gate returns a new or changed client configuration. On the first + * successful poll, this fires before the WorkStream opens — use it to initialize database + * connections, HTTP clients, or other resources that job/event handlers depend on. */ public Builder onConfigPolled(Consumer onConfigPolled) { this.onConfigPolled = onConfigPolled; diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index d205d85..1fbc233 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -153,6 +153,13 @@ public void onError(Throwable t) { lastDisconnect = Instant.now(); connectedSince = null; 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(); } From 273c40a6f395a725a7ce769ac21ddaa39ab8453e Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:31:50 -0600 Subject: [PATCH 17/50] Unify JobHandler + EventHandler + config into Plugin interface New Plugin interface extends both JobHandler and EventHandler, and adds onConfig(ClientConfiguration) for config validation. The WorkStream only opens after the plugin accepts the first config. - Plugin.onConfig() returns boolean: true = ready, false = reject - Plugin.pluginName() for diagnostics - ExileClient.builder().plugin(myPlugin) replaces separate jobHandler/eventHandler/onConfigPolled - ExileClientManager.builder().plugin(myPlugin) replaces separate handlers + onConfigChange callback - DemoPlugin replaces DemoJobHandler + DemoEventHandler Integration usage: var manager = ExileClientManager.builder() .clientName("sati-finvi") .plugin(new FinviPlugin(dataSource)) .build(); --- .../tcn/exile/config/ExileClientManager.java | 94 +++----------- .../main/java/com/tcn/exile/ExileClient.java | 85 +++++++------ .../java/com/tcn/exile/handler/Plugin.java | 44 +++++++ .../com/tcn/exile/demo/DemoEventHandler.java | 71 ----------- .../{DemoJobHandler.java => DemoPlugin.java} | 115 +++++++++++++++--- .../main/java/com/tcn/exile/demo/Main.java | 9 +- 6 files changed, 202 insertions(+), 216 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/handler/Plugin.java delete mode 100644 demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java rename demo/src/main/java/com/tcn/exile/demo/{DemoJobHandler.java => DemoPlugin.java} (58%) diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java index 60d049b..1ae9648 100644 --- a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -3,8 +3,7 @@ import com.tcn.exile.ExileClient; import com.tcn.exile.ExileConfig; import com.tcn.exile.StreamStatus; -import com.tcn.exile.handler.EventHandler; -import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.handler.Plugin; import java.io.IOException; import java.nio.file.Path; import java.util.List; @@ -12,24 +11,12 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Manages the lifecycle of a single-tenant {@link ExileClient} driven by a config file. * - *

Replaces the 400-500 line ConfigChangeWatcher that was copy-pasted across finvi, capone, - * latitude, and debtnet. Handles: - * - *

    - *
  • Config file watching and parsing - *
  • ExileClient creation and destruction on config changes - *
  • Org change detection (destroys old client, creates new one) - *
  • Periodic certificate rotation - *
  • Graceful shutdown - *
- * *

Usage: * *

{@code
@@ -37,21 +24,10 @@
  *     .clientName("sati-finvi")
  *     .clientVersion("3.0.0")
  *     .maxConcurrency(5)
- *     .jobHandler(new FinviJobHandler(dataSource))
- *     .eventHandler(new FinviEventHandler(dataSource))
- *     .onConfigChange(config -> reinitializeDataSource(config))
+ *     .plugin(new FinviPlugin(dataSource))
  *     .build();
  *
  * manager.start();
- *
- * // Access the active client.
- * var agents = manager.client().agents().listAgents(...);
- *
- * // Check health.
- * var status = manager.client().streamStatus();
- *
- * // Shut down.
- * manager.stop();
  * }
*/ public final class ExileClientManager implements AutoCloseable { @@ -61,9 +37,7 @@ public final class ExileClientManager implements AutoCloseable { private final String clientName; private final String clientVersion; private final int maxConcurrency; - private final JobHandler jobHandler; - private final EventHandler eventHandler; - private final Consumer onConfigChange; + private final Plugin plugin; private final List watchDirs; private final int certRotationHours; @@ -77,9 +51,7 @@ private ExileClientManager(Builder builder) { this.clientName = builder.clientName; this.clientVersion = builder.clientVersion; this.maxConcurrency = builder.maxConcurrency; - this.jobHandler = builder.jobHandler; - this.eventHandler = builder.eventHandler; - this.onConfigChange = builder.onConfigChange; + this.plugin = builder.plugin; this.watchDirs = builder.watchDirs; this.certRotationHours = builder.certRotationHours; } @@ -92,7 +64,6 @@ public void start() throws IOException { : ConfigFileWatcher.create(new WatcherListener()); watcher.start(); - // Schedule certificate rotation. scheduler = Executors.newSingleThreadScheduledExecutor( r -> { @@ -113,7 +84,8 @@ public void start() throws IOException { certRotationHours, TimeUnit.HOURS); - log.info("ExileClientManager started (clientName={})", clientName); + log.info( + "ExileClientManager started (clientName={}, plugin={})", clientName, plugin.pluginName()); } /** Returns the currently active client, or null if no config is loaded. */ @@ -127,12 +99,10 @@ public StreamStatus streamStatus() { return c != null ? c.streamStatus() : null; } - /** Returns the config file watcher (for writing rotated certs). */ ConfigFileWatcher configWatcher() { return watcher; } - /** Stop the manager, close the client, and stop watching. */ public void stop() { log.info("Stopping ExileClientManager"); destroyClient(); @@ -147,27 +117,17 @@ public void close() { private void createClient(ExileConfig config) { log.info( - "Creating ExileClient (endpoint={}:{}, org={})", + "Creating ExileClient (endpoint={}:{}, org={}, plugin={})", config.apiHostname(), config.apiPort(), - config.org()); + 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(); } - // Notify integration of config change (e.g., reinit datasource). - if (onConfigChange != null) { - try { - onConfigChange.accept(config); - } catch (Exception e) { - log.error("onConfigChange callback failed: {}", e.getMessage(), e); - return; - } - } - - // Destroy existing client if any (handles reconnect with new certs). destroyClient(); try { @@ -177,8 +137,7 @@ private void createClient(ExileConfig config) { .clientName(clientName) .clientVersion(clientVersion) .maxConcurrency(maxConcurrency) - .jobHandler(jobHandler) - .eventHandler(eventHandler) + .plugin(plugin) .build(); activeClient.start(); activeConfig = config; @@ -226,15 +185,12 @@ public static final class Builder { private String clientName = "sati"; private String clientVersion = "unknown"; private int maxConcurrency = 5; - private JobHandler jobHandler = new JobHandler() {}; - private EventHandler eventHandler = new EventHandler() {}; - private Consumer onConfigChange; + private Plugin plugin; private List watchDirs; private int certRotationHours = 1; private Builder() {} - /** Human-readable client name for diagnostics. */ public Builder clientName(String clientName) { this.clientName = Objects.requireNonNull(clientName); return this; @@ -250,44 +206,24 @@ public Builder maxConcurrency(int maxConcurrency) { return this; } - public Builder jobHandler(JobHandler jobHandler) { - this.jobHandler = Objects.requireNonNull(jobHandler); - return this; - } - - public Builder eventHandler(EventHandler eventHandler) { - this.eventHandler = Objects.requireNonNull(eventHandler); - return this; - } - - /** - * Callback invoked when config changes, before the ExileClient is (re)created. Use this to - * reinitialize integration-specific resources like database connections. - * - *

The callback receives the new {@link ExileConfig}. If it throws, the client will not be - * created. - */ - public Builder onConfigChange(Consumer onConfigChange) { - this.onConfigChange = onConfigChange; + /** The plugin that handles jobs, events, and config validation. */ + public Builder plugin(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin); return this; } - /** - * Override the default config directory paths. Defaults to {@code /workdir/config} and {@code - * workdir/config}. - */ public Builder watchDirs(List watchDirs) { this.watchDirs = watchDirs; return this; } - /** How often to check certificate expiration (hours). Default: 1. */ 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/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index b1ee8af..0886f52 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -1,7 +1,6 @@ package com.tcn.exile; -import com.tcn.exile.handler.EventHandler; -import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.handler.Plugin; import com.tcn.exile.internal.ChannelFactory; import com.tcn.exile.internal.WorkStreamClient; import com.tcn.exile.service.*; @@ -14,27 +13,28 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; 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 and the {@code onConfigPolled} callback completes - * without error. This ensures the integration's resources (database, HTTP client) are initialized - * before any work items arrive. + *

On {@link #start()}, only the config poller begins. The WorkStream does not open until: + * + *

    + *
  1. The first successful config poll from the gate + *
  2. 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 Consumer onConfigPolled; private final Duration configPollInterval; private final AgentService agentService; @@ -49,14 +49,14 @@ public final class ExileClient implements AutoCloseable { private ExileClient(Builder builder) { this.config = builder.config; - this.onConfigPolled = builder.onConfigPolled; + this.plugin = builder.plugin; this.configPollInterval = builder.configPollInterval; this.workStream = new WorkStreamClient( config, - builder.jobHandler, - builder.eventHandler, + plugin, + plugin, builder.clientName, builder.clientVersion, builder.maxConcurrency, @@ -82,18 +82,16 @@ private ExileClient(Builder builder) { /** * Start the client. Only the config poller begins immediately. The WorkStream opens after the - * first successful config poll and onConfigPolled callback. + * plugin accepts the first config via {@link Plugin#onConfig}. */ public void start() { log.info( - "Starting ExileClient for org={} (waiting for gate config before opening stream)", - config.org()); + "Starting ExileClient for org={} (plugin={}, waiting for config)", + config.org(), + plugin.pluginName()); configPoller.scheduleAtFixedRate( - this::pollConfig, - 0, // poll immediately on start - configPollInterval.toSeconds(), - TimeUnit.SECONDS); + this::pollConfig, 0, configPollInterval.toSeconds(), TimeUnit.SECONDS); } private void pollConfig() { @@ -104,17 +102,28 @@ private void pollConfig() { boolean changed = lastConfig == null || !newConfig.equals(lastConfig); lastConfig = newConfig; - if (changed && onConfigPolled != null) { + if (changed) { log.info( - "Config polled from gate (org={}, configName={}, changed=true)", + "Config received from gate (org={}, configName={})", newConfig.orgId(), newConfig.configName()); - onConfigPolled.accept(newConfig); + + boolean ready; + try { + ready = plugin.onConfig(newConfig); + } catch (Exception e) { + log.warn("Plugin {} rejected config: {}", plugin.pluginName(), e.getMessage()); + return; + } + + if (!ready) { + log.warn("Plugin {} not ready — WorkStream will not start yet", plugin.pluginName()); + return; + } } - // Start WorkStream on first successful config poll. if (workStreamStarted.compareAndSet(false, true)) { - log.info("Gate config received, starting WorkStream"); + log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); workStream.start(); } } catch (Exception e) { @@ -135,6 +144,10 @@ public ExileConfig config() { return config; } + public Plugin plugin() { + return plugin; + } + public AgentService agents() { return agentService; } @@ -173,13 +186,11 @@ public static Builder builder() { public static final class Builder { private ExileConfig config; - private JobHandler jobHandler = new JobHandler() {}; - private EventHandler eventHandler = new EventHandler() {}; + private Plugin plugin; private String clientName = "sati"; private String clientVersion = "unknown"; private int maxConcurrency = 5; private List capabilities = new ArrayList<>(); - private Consumer onConfigPolled; private Duration configPollInterval = Duration.ofSeconds(10); private Builder() {} @@ -189,13 +200,9 @@ public Builder config(ExileConfig config) { return this; } - public Builder jobHandler(JobHandler jobHandler) { - this.jobHandler = Objects.requireNonNull(jobHandler); - return this; - } - - public Builder eventHandler(EventHandler eventHandler) { - this.eventHandler = Objects.requireNonNull(eventHandler); + /** The plugin that handles jobs, events, and config validation. */ + public Builder plugin(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin); return this; } @@ -215,25 +222,15 @@ public Builder maxConcurrency(int maxConcurrency) { return this; } - /** - * Callback invoked when the gate returns a new or changed client configuration. On the first - * successful poll, this fires before the WorkStream opens — use it to initialize database - * connections, HTTP clients, or other resources that job/event handlers depend on. - */ - public Builder onConfigPolled(Consumer onConfigPolled) { - this.onConfigPolled = onConfigPolled; - 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; } - @SuppressWarnings("unchecked") 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/handler/Plugin.java b/core/src/main/java/com/tcn/exile/handler/Plugin.java new file mode 100644 index 0000000..c7e2d46 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/Plugin.java @@ -0,0 +1,44 @@ +package com.tcn.exile.handler; + +import com.tcn.exile.service.ConfigService; + +/** + * The single integration point for CRM plugins. Implementations provide job handling, event + * handling, and config validation in one place. + * + *

Lifecycle: + * + *

    + *
  1. Config is polled from the gate + *
  2. {@link #onConfig} is called — plugin validates and initializes resources + *
  3. If {@code onConfig} returns {@code true}, the WorkStream opens + *
  4. Jobs arrive → {@link JobHandler} methods are called + *
  5. Events arrive → {@link EventHandler} methods are called + *
+ * + *

Extend this interface and override the methods you need. All methods have default + * implementations (jobs throw UnsupportedOperationException, events are no-ops, config always + * accepted). + */ +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. + * + * @param config the client configuration from the gate, including the plugin-specific payload + * @return true if the plugin is configured and ready to process work + */ + default boolean onConfig(ConfigService.ClientConfiguration config) { + return true; + } + + /** Human-readable plugin name for diagnostics. */ + default String pluginName() { + return getClass().getSimpleName(); + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java b/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java deleted file mode 100644 index cd94f10..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/DemoEventHandler.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.tcn.exile.demo; - -import com.tcn.exile.handler.EventHandler; -import com.tcn.exile.model.event.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Logs all received events. In a real integration, these would be persisted to a database. */ -public class DemoEventHandler implements EventHandler { - - private static final Logger log = LoggerFactory.getLogger(DemoEventHandler.class); - - @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/DemoJobHandler.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java similarity index 58% rename from demo/src/main/java/com/tcn/exile/demo/DemoJobHandler.java rename to demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java index c36990d..992658e 100644 --- a/demo/src/main/java/com/tcn/exile/demo/DemoJobHandler.java +++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java @@ -1,7 +1,9 @@ package com.tcn.exile.demo; -import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.handler.Plugin; import com.tcn.exile.model.*; +import com.tcn.exile.model.event.*; +import com.tcn.exile.service.ConfigService; import java.time.Instant; import java.util.List; import java.util.Map; @@ -9,12 +11,35 @@ import org.slf4j.LoggerFactory; /** - * Stub job handler that returns fake data for demonstration and testing. Each method logs the - * request and returns plausible dummy responses. + * Demo plugin that validates config, returns stub job data, and logs events. In a real integration, + * this would initialize a database connection on config and execute stored procedures for jobs. */ -public class DemoJobHandler implements JobHandler { +public class DemoPlugin implements Plugin { - private static final Logger log = LoggerFactory.getLogger(DemoJobHandler.class); + 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. + configured = true; + return true; + } + + @Override + public String pluginName() { + return "demo"; + } + + // --- Jobs --- @Override public List listPools() { @@ -33,20 +58,20 @@ public Pool getPoolStatus(String poolId) { @Override public Page getPoolRecords(String poolId, String pageToken, int pageSize) { log.info("getPoolRecords called for pool={} page={} size={}", poolId, pageToken, pageSize); - var records = + 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"))); - return new Page<>(records, ""); + 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()); - var records = + return new Page<>( List.of( - new DataRecord("pool-1", "rec-1", Map.of("name", "Search Result", "matched", true))); - return new Page<>(records, ""); + new DataRecord("pool-1", "rec-1", Map.of("name", "Search Result", "matched", true))), + ""); } @Override @@ -92,13 +117,14 @@ public Map info() { "runtime", System.getProperty("java.version"), "os", - System.getProperty("os.name")); + System.getProperty("os.name"), + "configured", + configured); } @Override public void shutdown(String reason) { log.warn("Shutdown requested: {}", reason); - // In a real integration, this would trigger graceful shutdown. } @Override @@ -118,7 +144,7 @@ public DiagnosticsInfo diagnostics() { "java.version", System.getProperty("java.version"), "heap.max", runtime.maxMemory(), "heap.used", runtime.totalMemory() - runtime.freeMemory()), - Map.of("type", "demo", "connected", false), + Map.of("type", "demo", "connected", configured), Map.of("demo", true)); } @@ -134,4 +160,65 @@ public Page listTenantLogs( public void setLogLevel(String loggerName, String level) { log.info("setLogLevel called: {}={}", loggerName, level); } + + // --- 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 index c56a33b..34f4fca 100644 --- a/demo/src/main/java/com/tcn/exile/demo/Main.java +++ b/demo/src/main/java/com/tcn/exile/demo/Main.java @@ -55,14 +55,7 @@ public static void main(String[] args) throws Exception { .clientName("sati-demo") .clientVersion(VERSION) .maxConcurrency(5) - .jobHandler(new DemoJobHandler()) - .eventHandler(new DemoEventHandler()) - .onConfigChange( - config -> { - log.info("Config loaded for org={}", config.org()); - // In a real integration, you would initialize your database - // connection pool or HTTP client here. - }); + .plugin(new DemoPlugin()); if (!configDir.isEmpty()) { builder.watchDirs(List.of(Path.of(configDir))); From 56e7e15acc88673f7d4b922aef3af28db4c812f0 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:34:30 -0600 Subject: [PATCH 18/50] Fix Map vs String comparison in DemoPlugin.onConfig --- demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java index 992658e..3a1cb1e 100644 --- a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java +++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java @@ -30,6 +30,10 @@ public boolean onConfig(ConfigService.ClientConfiguration config) { 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; } From 542f303d170d6dd4bdf595cd7c3a3335b86d4720 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:37:25 -0600 Subject: [PATCH 19/50] Fix WorkStream starting without plugin approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second config poll (unchanged config) was bypassing the plugin check and starting the WorkStream. Now tracks pluginReady separately from workStreamStarted — the stream only opens if the plugin has explicitly returned true from onConfig(). --- core/src/main/java/com/tcn/exile/ExileClient.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 0886f52..106955d 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -45,6 +45,7 @@ public final class ExileClient implements AutoCloseable { private final JourneyService journeyService; private volatile ConfigService.ClientConfiguration lastConfig; + private final AtomicBoolean pluginReady = new AtomicBoolean(false); private final AtomicBoolean workStreamStarted = new AtomicBoolean(false); private ExileClient(Builder builder) { @@ -113,16 +114,21 @@ private void pollConfig() { 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); } - if (workStreamStarted.compareAndSet(false, true)) { + // Only start WorkStream if plugin has explicitly accepted a config. + if (pluginReady.get() && workStreamStarted.compareAndSet(false, true)) { log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); workStream.start(); } From 5381e2580bd092a790d7966c02a2c09205b4e65e Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:44:47 -0600 Subject: [PATCH 20/50] Add default listTenantLogs and setLogLevel in Plugin using logback-ext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin now provides default implementations for: - listTenantLogs: reads from MemoryAppender's in-memory log buffer - setLogLevel: uses logback API to change runtime log levels - info: returns basic JVM/OS metadata - diagnostics: returns JVM heap/OS stats Integrations no longer need to implement these — they get real log retrieval and level changes for free. - core depends on logback-ext for MemoryAppender access - logback-ext exports logback-classic as api (was implementation) - demo logback.xml adds MemoryAppender alongside console - DemoPlugin removes redundant overrides that are now defaults --- core/build.gradle | 3 + .../java/com/tcn/exile/handler/Plugin.java | 80 +++++++++++++++++-- .../java/com/tcn/exile/demo/DemoPlugin.java | 45 ----------- demo/src/main/resources/logback.xml | 7 ++ logback-ext/build.gradle | 6 +- 5 files changed, 87 insertions(+), 54 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 256929b..75edf47 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -24,6 +24,9 @@ dependencies { // PKCS#1 → PKCS#8 key conversion for mTLS implementation("org.bouncycastle:bcpkix-jdk18on:1.79") + // In-memory log buffer for listTenantLogs/setLogLevel + implementation(project(':logback-ext')) + // javax.annotation for @Generated in gRPC stubs compileOnly("org.apache.tomcat:annotations-api:6.0.53") diff --git a/core/src/main/java/com/tcn/exile/handler/Plugin.java b/core/src/main/java/com/tcn/exile/handler/Plugin.java index c7e2d46..db6e4b0 100644 --- a/core/src/main/java/com/tcn/exile/handler/Plugin.java +++ b/core/src/main/java/com/tcn/exile/handler/Plugin.java @@ -1,6 +1,12 @@ package com.tcn.exile.handler; +import com.tcn.exile.memlogger.MemoryAppender; +import com.tcn.exile.memlogger.MemoryAppenderInstance; +import com.tcn.exile.model.Page; import com.tcn.exile.service.ConfigService; +import java.time.Instant; +import java.util.List; +import java.util.Map; /** * The single integration point for CRM plugins. Implementations provide job handling, event @@ -16,9 +22,9 @@ *

  • Events arrive → {@link EventHandler} methods are called * * - *

    Extend this interface and override the methods you need. All methods have default - * implementations (jobs throw UnsupportedOperationException, events are no-ops, config always - * accepted). + *

    Default implementations for {@code listTenantLogs}, {@code setLogLevel}, {@code info}, and + * {@code diagnostics} are provided using logback-ext's MemoryAppender. Integrations can override + * these if needed. */ public interface Plugin extends JobHandler, EventHandler { @@ -29,9 +35,6 @@ public interface Plugin extends JobHandler, EventHandler { *

    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. - * - * @param config the client configuration from the gate, including the plugin-specific payload - * @return true if the plugin is configured and ready to process work */ default boolean onConfig(ConfigService.ClientConfiguration config) { return true; @@ -41,4 +44,69 @@ default boolean onConfig(ConfigService.ClientConfiguration config) { default String pluginName() { return getClass().getSimpleName(); } + + /** + * Returns log entries from the in-memory log buffer. Default implementation reads from + * logback-ext's MemoryAppender. + */ + @Override + default 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.getEventsInTimeRange(startMs, endMs); + var entries = + logEvents.stream() + .map(msg -> new JobHandler.LogEntry(Instant.now(), "INFO", "memlogger", msg)) + .limit(pageSize > 0 ? pageSize : 100) + .toList(); + + return new Page<>(entries, ""); + } + + /** + * Changes the log level at runtime. Default implementation uses logback's API to set the level on + * the named logger. + */ + @Override + default 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) { + logger.setLevel(ch.qos.logback.classic.Level.valueOf(level)); + } + } + + /** Returns basic app info. Default implementation returns runtime metadata. */ + @Override + default Map info() throws Exception { + return Map.of( + "appName", pluginName(), + "runtime", System.getProperty("java.version"), + "os", System.getProperty("os.name")); + } + + /** Returns system diagnostics. Default implementation returns JVM stats. */ + @Override + default JobHandler.DiagnosticsInfo diagnostics() throws Exception { + var runtime = Runtime.getRuntime(); + return new JobHandler.DiagnosticsInfo( + Map.of( + "os", System.getProperty("os.name"), + "arch", System.getProperty("os.arch"), + "processors", runtime.availableProcessors()), + Map.of( + "java.version", System.getProperty("java.version"), + "heap.max", runtime.maxMemory(), + "heap.used", runtime.totalMemory() - runtime.freeMemory()), + Map.of(), + Map.of()); + } } diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java index 3a1cb1e..f61774c 100644 --- a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java +++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java @@ -4,7 +4,6 @@ import com.tcn.exile.model.*; import com.tcn.exile.model.event.*; import com.tcn.exile.service.ConfigService; -import java.time.Instant; import java.util.List; import java.util.Map; import org.slf4j.Logger; @@ -111,21 +110,6 @@ public Map executeLogic(String logicName, Map pa return Map.of("result", "ok", "logic", logicName); } - @Override - public Map info() { - return Map.of( - "appName", - "sati-demo", - "appVersion", - Main.VERSION, - "runtime", - System.getProperty("java.version"), - "os", - System.getProperty("os.name"), - "configured", - configured); - } - @Override public void shutdown(String reason) { log.warn("Shutdown requested: {}", reason); @@ -136,35 +120,6 @@ public void processLog(String payload) { log.info("Remote log: {}", payload); } - @Override - public DiagnosticsInfo diagnostics() { - var runtime = Runtime.getRuntime(); - return new DiagnosticsInfo( - Map.of( - "os", System.getProperty("os.name"), - "arch", System.getProperty("os.arch"), - "processors", runtime.availableProcessors()), - Map.of( - "java.version", System.getProperty("java.version"), - "heap.max", runtime.maxMemory(), - "heap.used", runtime.totalMemory() - runtime.freeMemory()), - Map.of("type", "demo", "connected", configured), - Map.of("demo", true)); - } - - @Override - public Page listTenantLogs( - Instant startTime, Instant endTime, String pageToken, int pageSize) { - log.info("listTenantLogs called from {} to {}", startTime, endTime); - return new Page<>( - List.of(new LogEntry(Instant.now(), "INFO", "demo", "This is a demo log entry")), ""); - } - - @Override - public void setLogLevel(String loggerName, String level) { - log.info("setLogLevel called: {}={}", loggerName, level); - } - // --- Events --- @Override diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml index 5293f61..6ba014d 100644 --- a/demo/src/main/resources/logback.xml +++ b/demo/src/main/resources/logback.xml @@ -5,11 +5,18 @@ + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + diff --git a/logback-ext/build.gradle b/logback-ext/build.gradle index 2de30bc..95226f2 100644 --- a/logback-ext/build.gradle +++ b/logback-ext/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'java' + id 'java-library' } repositories { @@ -12,8 +12,8 @@ dependencies { implementation 'org.slf4j:slf4j-api:2.0.9' // Logback dependencies - implementation 'ch.qos.logback:logback-classic:1.4.14' - implementation 'ch.qos.logback:logback-core:1.4.14' + api 'ch.qos.logback:logback-classic:1.4.14' + api 'ch.qos.logback:logback-core:1.4.14' // Testing testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' From 7c2d8355666f4abbea88fec018b715d64c596683 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:47:00 -0600 Subject: [PATCH 21/50] Extract PluginBase abstract class for common operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin interface is now lean — just onConfig() and pluginName(). PluginBase provides default implementations for: - listTenantLogs (reads from MemoryAppender) - setLogLevel (changes logback level at runtime) - info (JVM/OS metadata) - diagnostics (heap stats, processors) - shutdown (logs warning) - processLog (logs payload) Integrations extend PluginBase and only override CRM-specific methods: public class FinviPlugin extends PluginBase { public boolean onConfig(...) { ... } public List listPools() { ... } public void onTelephonyResult(...) { ... } } --- .../java/com/tcn/exile/handler/Plugin.java | 86 +----------- .../com/tcn/exile/handler/PluginBase.java | 123 ++++++++++++++++++ .../java/com/tcn/exile/demo/DemoPlugin.java | 19 +-- 3 files changed, 134 insertions(+), 94 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/handler/PluginBase.java diff --git a/core/src/main/java/com/tcn/exile/handler/Plugin.java b/core/src/main/java/com/tcn/exile/handler/Plugin.java index db6e4b0..0718cb7 100644 --- a/core/src/main/java/com/tcn/exile/handler/Plugin.java +++ b/core/src/main/java/com/tcn/exile/handler/Plugin.java @@ -1,16 +1,13 @@ package com.tcn.exile.handler; -import com.tcn.exile.memlogger.MemoryAppender; -import com.tcn.exile.memlogger.MemoryAppenderInstance; -import com.tcn.exile.model.Page; import com.tcn.exile.service.ConfigService; -import java.time.Instant; -import java.util.List; -import java.util.Map; /** - * The single integration point for CRM plugins. Implementations provide job handling, event - * handling, and config validation in one place. + * 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: * @@ -21,10 +18,6 @@ *

  • Jobs arrive → {@link JobHandler} methods are called *
  • Events arrive → {@link EventHandler} methods are called * - * - *

    Default implementations for {@code listTenantLogs}, {@code setLogLevel}, {@code info}, and - * {@code diagnostics} are provided using logback-ext's MemoryAppender. Integrations can override - * these if needed. */ public interface Plugin extends JobHandler, EventHandler { @@ -36,77 +29,10 @@ public interface Plugin extends JobHandler, EventHandler { * the first {@code true} return. Return {@code false} to reject the config — the poller will * retry on the next cycle. */ - default boolean onConfig(ConfigService.ClientConfiguration config) { - return true; - } + boolean onConfig(ConfigService.ClientConfiguration config); /** Human-readable plugin name for diagnostics. */ default String pluginName() { return getClass().getSimpleName(); } - - /** - * Returns log entries from the in-memory log buffer. Default implementation reads from - * logback-ext's MemoryAppender. - */ - @Override - default 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.getEventsInTimeRange(startMs, endMs); - var entries = - logEvents.stream() - .map(msg -> new JobHandler.LogEntry(Instant.now(), "INFO", "memlogger", msg)) - .limit(pageSize > 0 ? pageSize : 100) - .toList(); - - return new Page<>(entries, ""); - } - - /** - * Changes the log level at runtime. Default implementation uses logback's API to set the level on - * the named logger. - */ - @Override - default 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) { - logger.setLevel(ch.qos.logback.classic.Level.valueOf(level)); - } - } - - /** Returns basic app info. Default implementation returns runtime metadata. */ - @Override - default Map info() throws Exception { - return Map.of( - "appName", pluginName(), - "runtime", System.getProperty("java.version"), - "os", System.getProperty("os.name")); - } - - /** Returns system diagnostics. Default implementation returns JVM stats. */ - @Override - default JobHandler.DiagnosticsInfo diagnostics() throws Exception { - var runtime = Runtime.getRuntime(); - return new JobHandler.DiagnosticsInfo( - Map.of( - "os", System.getProperty("os.name"), - "arch", System.getProperty("os.arch"), - "processors", runtime.availableProcessors()), - Map.of( - "java.version", System.getProperty("java.version"), - "heap.max", runtime.maxMemory(), - "heap.used", runtime.totalMemory() - runtime.freeMemory()), - Map.of(), - Map.of()); - } } 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..06e4fa0 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java @@ -0,0 +1,123 @@ +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(); + 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(); + + 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(); + return new JobHandler.DiagnosticsInfo( + Map.of( + "os", System.getProperty("os.name"), + "arch", System.getProperty("os.arch"), + "processors", rt.availableProcessors()), + Map.of( + "java.version", System.getProperty("java.version"), + "heap.max", rt.maxMemory(), + "heap.used", rt.totalMemory() - rt.freeMemory()), + Map.of(), + Map.of()); + } + + // --- Shutdown --- + + @Override + public void shutdown(String reason) throws Exception { + log.warn("Shutdown requested: {}", reason); + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java index f61774c..6b66460 100644 --- a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java +++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java @@ -1,6 +1,6 @@ package com.tcn.exile.demo; -import com.tcn.exile.handler.Plugin; +import com.tcn.exile.handler.PluginBase; import com.tcn.exile.model.*; import com.tcn.exile.model.event.*; import com.tcn.exile.service.ConfigService; @@ -10,10 +10,11 @@ import org.slf4j.LoggerFactory; /** - * Demo plugin that validates config, returns stub job data, and logs events. In a real integration, - * this would initialize a database connection on config and execute stored procedures for jobs. + * 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 implements Plugin { +public class DemoPlugin extends PluginBase { private static final Logger log = LoggerFactory.getLogger(DemoPlugin.class); private volatile boolean configured = false; @@ -110,16 +111,6 @@ public Map executeLogic(String logicName, Map pa return Map.of("result", "ok", "logic", logicName); } - @Override - public void shutdown(String reason) { - log.warn("Shutdown requested: {}", reason); - } - - @Override - public void processLog(String payload) { - log.info("Remote log: {}", payload); - } - // --- Events --- @Override From c2703763d92ebdf6d650d332ab6b075bbfaaaafd Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:52:35 -0600 Subject: [PATCH 22/50] Add /logs endpoint and debug logging for MemoryAppender - StatusServer: add /logs endpoint that dumps MemoryAppender contents as JSON (appender_exists, event_count, first 50 entries with timestamps) - PluginBase: add debug logs showing appender event count and returned entry count in listTenantLogs - Demo: add logback-ext dependency for /logs endpoint --- .../com/tcn/exile/handler/PluginBase.java | 6 ++++ demo/build.gradle | 1 + .../java/com/tcn/exile/demo/StatusServer.java | 33 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/core/src/main/java/com/tcn/exile/handler/PluginBase.java b/core/src/main/java/com/tcn/exile/handler/PluginBase.java index 06e4fa0..491ba7a 100644 --- a/core/src/main/java/com/tcn/exile/handler/PluginBase.java +++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java @@ -58,6 +58,11 @@ public Page listTenantLogs( 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) @@ -67,6 +72,7 @@ public Page listTenantLogs( 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, ""); } diff --git a/demo/build.gradle b/demo/build.gradle index 58b96ad..b30cc44 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -15,6 +15,7 @@ shadowJar { dependencies { implementation(project(':core')) implementation(project(':config')) + implementation(project(':logback-ext')) // Logging runtime runtimeOnly('ch.qos.logback:logback-classic:1.5.17') diff --git a/demo/src/main/java/com/tcn/exile/demo/StatusServer.java b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java index d53d3da..75ffb45 100644 --- a/demo/src/main/java/com/tcn/exile/demo/StatusServer.java +++ b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java @@ -59,6 +59,38 @@ public StatusServer(ExileClientManager manager, int port) throws IOException { } }); + server.createContext( + "/logs", + exchange -> { + var appender = com.tcn.exile.memlogger.MemoryAppenderInstance.getInstance(); + var sb = new StringBuilder(); + sb.append("{\"appender_exists\":"); + sb.append(appender != null); + if (appender != null) { + var events = appender.getEventsWithTimestamps(); + sb.append(",\"event_count\":").append(events.size()); + sb.append(",\"entries\":["); + int limit = Math.min(events.size(), 50); + for (int i = 0; i < limit; i++) { + if (i > 0) sb.append(","); + var e = events.get(i); + sb.append("{\"ts\":").append(e.timestamp); + sb.append(",\"msg\":\"") + .append( + e.message.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")) + .append("\"}"); + } + sb.append("]"); + } + sb.append("}"); + var body = sb.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.createContext( "/", exchange -> { @@ -72,6 +104,7 @@ public StatusServer(ExileClientManager manager, int port) throws IOException {
    • /health — health check
    • /status — stream status (JSON)
    • +
    • /logs — in-memory log buffer (JSON)
    From 277dc2c3482556762263f930f4e9701397bf7dd7 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:22:26 -0600 Subject: [PATCH 23/50] Implement comprehensive RunDiagnostics in PluginBase Collects: - System: OS, arch, processors, load average, hostname, container detection (docker/cgroup), pod name, storage per mount - Runtime: Java version/vendor/VM, heap (init/used/committed/max), non-heap, thread counts (live/daemon/peak/total started), uptime, GC stats per collector (count + time) - Database: empty by default, plugins override for connection pool stats - Custom: plugin name, MemoryAppender event count --- .../com/tcn/exile/handler/PluginBase.java | 96 ++++++++++++++++--- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/handler/PluginBase.java b/core/src/main/java/com/tcn/exile/handler/PluginBase.java index 491ba7a..1fec06c 100644 --- a/core/src/main/java/com/tcn/exile/handler/PluginBase.java +++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java @@ -107,17 +107,91 @@ public Map info() throws Exception { @Override public JobHandler.DiagnosticsInfo diagnostics() throws Exception { var rt = Runtime.getRuntime(); - return new JobHandler.DiagnosticsInfo( - Map.of( - "os", System.getProperty("os.name"), - "arch", System.getProperty("os.arch"), - "processors", rt.availableProcessors()), - Map.of( - "java.version", System.getProperty("java.version"), - "heap.max", rt.maxMemory(), - "heap.used", rt.totalMemory() - rt.freeMemory()), - Map.of(), - Map.of()); + 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 --- From bb6eed6e9fe7710ca3579d78cea058f98039aec4 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:24:21 -0600 Subject: [PATCH 24/50] Implement seppuku shutdown in PluginBase Waits 2 seconds (to allow the result to be sent back) then calls System.exit(0). The delay runs on a virtual thread so it doesn't block the work stream response. --- .../java/com/tcn/exile/handler/PluginBase.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/tcn/exile/handler/PluginBase.java b/core/src/main/java/com/tcn/exile/handler/PluginBase.java index 1fec06c..891063e 100644 --- a/core/src/main/java/com/tcn/exile/handler/PluginBase.java +++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java @@ -198,6 +198,18 @@ private static String getHostname() { @Override public void shutdown(String reason) throws Exception { - log.warn("Shutdown requested: {}", reason); + 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); + }); } } From e90a2d25bf4c56ebf03a24da6dfe80eadbd610af Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:24:04 -0600 Subject: [PATCH 25/50] Reuse gRPC channel across stream reconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel is now created once in start() and reused for all subsequent stream reconnects. Previously, every stream disconnect destroyed the channel and created a new one, requiring a full TLS handshake + DNS resolution + TCP connection each time. Now: stream drops → just open a new WorkStream on the existing channel. The channel handles TCP/TLS reconnection internally. Channel is only destroyed on close(). --- .../java/com/tcn/exile/internal/WorkStreamClient.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 1fbc233..f053045 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -95,6 +95,9 @@ 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); } @@ -132,9 +135,7 @@ private void reconnectLoop() { private void runStream() throws InterruptedException { phase = Phase.CONNECTING; - log.debug("Creating gRPC channel to {}:{}", config.apiHostname(), config.apiPort()); - channel = ChannelFactory.create(config); - log.debug("Channel created, opening WorkStream"); + log.debug("Opening WorkStream on existing channel"); try { var stub = WorkerServiceGrpc.newStub(channel); var latch = new CountDownLatch(1); @@ -190,8 +191,7 @@ public void onCompleted() { } finally { requestObserver.set(null); inflight.set(0); - ChannelFactory.shutdown(channel); - channel = null; + // Channel is reused across reconnects — only shut down on close(). } } From c39d99a07b0e86ea25d1e99e826683b489b8aed4 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:27:19 -0600 Subject: [PATCH 26/50] Add channel reuse benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compares new-channel-per-stream vs reused-channel performance using in-process gRPC transport. Results show 9-41x speedup with reuse: - avg: 1.37ms → 0.09ms (16x) - p50: 0.36ms → 0.04ms (9x) - p99: 92ms → 2.2ms (41x) - Concurrent: 17,983 streams/sec on single channel --- .../tcn/exile/internal/ChannelBenchmark.java | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java 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..57c846b --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java @@ -0,0 +1,220 @@ +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(); + } + + /** Minimal WorkerService that responds to Register with Registered. */ + static class BenchmarkWorkerService extends WorkerServiceGrpc.WorkerServiceImplBase { + @Override + public StreamObserver workStream(StreamObserver responseObserver) { + return new StreamObserver<>() { + @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()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + } +} From 4bbdc7b878ac336e2ff4771c1c7bb5122b977498 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:29:02 -0600 Subject: [PATCH 27/50] Add message throughput and round-trip latency benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New benchmarks on single reused channel: - Throughput: 208K msgs/sec (10K messages in 48ms) - Round-trip: 2.6µs avg, 1.4µs p50, 16.8µs p99 - Channel reuse: 137x avg, 344x p99 faster than new-channel-per-stream --- .../tcn/exile/internal/ChannelBenchmark.java | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java index 57c846b..cfd84a0 100644 --- a/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java +++ b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java @@ -185,11 +185,138 @@ public void onCompleted() { observer.onCompleted(); } - /** Minimal WorkerService that responds to Register with Registered. */ + @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()) { @@ -204,6 +331,27 @@ public void onNext(WorkRequest request) { 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()); } } From 977ac59658c12b5fd34299e1371285174d456efe Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:07:33 -0600 Subject: [PATCH 28/50] add flow control window tuning and benchmark tests - Set 4MB flow control window in ChannelFactory to match envoy upstream - Add in-process StreamBenchmark: throughput, payload, flow-controlled, ping - Add LiveBenchmark against exile.dev.tcn.com with mTLS --- .../tcn/exile/internal/ChannelFactory.java | 1 + .../com/tcn/exile/internal/LiveBenchmark.java | 330 +++++++++++++ .../tcn/exile/internal/StreamBenchmark.java | 452 ++++++++++++++++++ 3 files changed, 783 insertions(+) create mode 100644 core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java create mode 100644 core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java diff --git a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java index 1ae2bfd..960c27f 100644 --- a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java +++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java @@ -44,6 +44,7 @@ public static ManagedChannel create(ExileConfig config) { .keepAliveTimeout(30, 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); 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..aa44a53 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java @@ -0,0 +1,330 @@ +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 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 io.grpc.stub.StreamObserver; +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/StreamBenchmark.java b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java new file mode 100644 index 0000000..adee0e0 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java @@ -0,0 +1,452 @@ +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.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.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(); + } + } +} From 1830233e239d3a63a28a91518bc1cd78a61e6f05 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:07:46 -0600 Subject: [PATCH 29/50] add OTel metrics and structured log shipping to gate TelemetryService Metrics: - Add OpenTelemetry SDK with custom GrpcMetricExporter (60s interval) - MetricsManager registers built-in instruments: work completed/failed/ reconnects/inflight/phase, JVM heap/threads, work duration histogram - Expose Meter via ExileClient.meter() for plugin custom metrics - Record work item processing duration in WorkStreamClient Logs: - Enrich MemoryAppender.LogEvent with level, logger, thread, MDC, stack trace - Switch from per-event shipping to periodic 10s drain thread - Increase log buffer from 100 to 1000 events - Add shipStructuredLogs() default method to LogShipper interface - GrpcLogShipper converts structured logs to proto with OTel trace context - Wire log shipper lifecycle into ExileClient start/close BSR stubs bumped to pick up new TelemetryService proto. --- core/build.gradle | 5 + .../main/java/com/tcn/exile/ExileClient.java | 29 ++++ .../tcn/exile/internal/GrpcLogShipper.java | 100 ++++++++++++ .../exile/internal/GrpcMetricExporter.java | 55 +++++++ .../tcn/exile/internal/MetricsManager.java | 126 +++++++++++++++ .../tcn/exile/internal/WorkStreamClient.java | 25 ++- .../com/tcn/exile/service/ServiceFactory.java | 6 +- .../tcn/exile/service/TelemetryService.java | 147 ++++++++++++++++++ gradle.properties | 4 +- .../com/tcn/exile/memlogger/LogShipper.java | 5 + .../tcn/exile/memlogger/MemoryAppender.java | 100 ++++++++++-- 11 files changed, 579 insertions(+), 23 deletions(-) create mode 100644 core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java create mode 100644 core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java create mode 100644 core/src/main/java/com/tcn/exile/internal/MetricsManager.java create mode 100644 core/src/main/java/com/tcn/exile/service/TelemetryService.java diff --git a/core/build.gradle b/core/build.gradle index 75edf47..77a6d78 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -21,6 +21,11 @@ dependencies { // protobuf runtime api("com.google.protobuf:protobuf-java:${protobufVersion}") + // OpenTelemetry SDK — metrics collection 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") + // PKCS#1 → PKCS#8 key conversion for mTLS implementation("org.bouncycastle:bcpkix-jdk18on:1.79") diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 106955d..3737f80 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -2,8 +2,12 @@ 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.MemoryAppenderInstance; import com.tcn.exile.service.*; +import io.opentelemetry.api.metrics.Meter; import io.grpc.ManagedChannel; import java.time.Duration; import java.util.ArrayList; @@ -43,6 +47,7 @@ public final class ExileClient implements AutoCloseable { private final ScrubListService scrubListService; private final ConfigService configService; private final JourneyService journeyService; + private final MetricsManager metricsManager; private volatile ConfigService.ClientConfiguration lastConfig; private final AtomicBoolean pluginReady = new AtomicBoolean(false); @@ -72,6 +77,17 @@ private ExileClient(Builder builder) { this.configService = services.config(); this.journeyService = services.journey(); + // Telemetry: OTel metrics exported via gRPC to the gate. + var telemetryClientId = builder.clientName + "-" + java.util.UUID.randomUUID().toString().substring(0, 8); + this.metricsManager = new MetricsManager(services.telemetry(), telemetryClientId, workStream::status); + workStream.setDurationRecorder(metricsManager::recordWorkDuration); + + // Telemetry: structured log shipping via gRPC to the gate. + var appender = MemoryAppenderInstance.getInstance(); + if (appender != null) { + appender.enableLogShipper(new GrpcLogShipper(services.telemetry(), telemetryClientId)); + } + this.configPoller = Executors.newSingleThreadScheduledExecutor( r -> { @@ -178,10 +194,23 @@ 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. + */ + public Meter meter() { + return metricsManager.meter(); + } + @Override public void close() { log.info("Shutting down ExileClient"); configPoller.shutdownNow(); + var appender = MemoryAppenderInstance.getInstance(); + if (appender != null) { + appender.disableLogShipper(); + } + metricsManager.close(); workStream.close(); ChannelFactory.shutdown(serviceChannel); } 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..62a9a0e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java @@ -0,0 +1,100 @@ +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 io.opentelemetry.api.trace.Span; +import java.util.ArrayList; +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(event.message != null ? event.message : ""); + + 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); + + // Attach trace context from the current span if available. + var spanContext = Span.current().getSpanContext(); + if (spanContext.isValid()) { + builder.setTraceId(spanContext.getTraceId()); + builder.setSpanId(spanContext.getSpanId()); + } + + records.add(builder.build()); + } + sendRecords(records); + } + + 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..38d560e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -0,0 +1,126 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.StreamStatus; +import com.tcn.exile.service.TelemetryService; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +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 SdkMeterProvider meterProvider; + private final Meter meter; + private final DoubleHistogram workDuration; + + public MetricsManager( + TelemetryService telemetryService, + String clientId, + Supplier statusSupplier) { + + var exporter = new GrpcMetricExporter(telemetryService, clientId); + var reader = + PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build(); + + this.meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + 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(); + + log.info("MetricsManager initialized (export interval=60s, clientId={})", clientId); + } + + /** 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); + } + + @Override + public void close() { + meterProvider.close(); + log.info("MetricsManager shut down"); + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index f053045..a102557 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -59,6 +59,7 @@ public final class WorkStreamClient implements AutoCloseable { private volatile ManagedChannel channel; private volatile Thread streamThread; + private volatile java.util.function.DoubleConsumer durationRecorder; public WorkStreamClient( ExileConfig config, @@ -247,7 +248,13 @@ private void handleResponse(WorkResponse response) { } } + /** Set a callback to record work item processing duration (in seconds). */ + public void setDurationRecorder(java.util.function.DoubleConsumer recorder) { + this.durationRecorder = recorder; + } + private void processWorkItem(WorkItem item) { + long startNanos = System.nanoTime(); String workId = item.getWorkId(); try { if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { @@ -270,8 +277,16 @@ private void processWorkItem(WorkItem item) { .setError(ErrorResult.newBuilder().setMessage(e.getMessage()))) .build()); } finally { - inflight.decrementAndGet(); - pull(1); + var recorder = durationRecorder; + if (recorder != null) { + recorder.accept((System.nanoTime() - startNanos) / 1_000_000_000.0); + } + int remaining = inflight.decrementAndGet(); + // Pull enough to fill capacity. Avoids 1-at-a-time round-trips over a 32ms RTT. + int available = maxConcurrency - remaining; + if (available > 0) { + pull(available); + } } } @@ -434,6 +449,12 @@ private void send(WorkRequest 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) {} + } } } } diff --git a/core/src/main/java/com/tcn/exile/service/ServiceFactory.java b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java index 0d6e322..15d3cb8 100644 --- a/core/src/main/java/com/tcn/exile/service/ServiceFactory.java +++ b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java @@ -12,7 +12,8 @@ public record Services( RecordingService recording, ScrubListService scrubList, ConfigService config, - JourneyService journey) {} + JourneyService journey, + TelemetryService telemetry) {} public static Services create(ManagedChannel channel) { return new Services( @@ -21,6 +22,7 @@ public static Services create(ManagedChannel channel) { new RecordingService(channel), new ScrubListService(channel), new ConfigService(channel), - new JourneyService(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..900c9b9 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/TelemetryService.java @@ -0,0 +1,147 @@ +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(); + + switch (metric.getType()) { + case LONG_GAUGE -> { + for (var point : metric.getLongGaugeData().getPoints()) { + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_GAUGE) + .putAllAttributes(attributesToMap(point.getAttributes())) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case DOUBLE_GAUGE -> { + for (var point : metric.getDoubleGaugeData().getPoints()) { + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_GAUGE) + .putAllAttributes(attributesToMap(point.getAttributes())) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case LONG_SUM -> { + for (var point : metric.getLongSumData().getPoints()) { + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_SUM) + .putAllAttributes(attributesToMap(point.getAttributes())) + .setTime(toTimestamp(point.getEpochNanos())) + .setIntValue(point.getValue()) + .build()); + } + } + case DOUBLE_SUM -> { + for (var point : metric.getDoubleSumData().getPoints()) { + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_SUM) + .putAllAttributes(attributesToMap(point.getAttributes())) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case HISTOGRAM -> { + for (var point : metric.getHistogramData().getPoints()) { + 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(attributesToMap(point.getAttributes())) + .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; + } + + 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/gradle.properties b/gradle.properties index dc17e65..c0235ee 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ grpcVersion=1.80.0 protobufVersion=4.28.3 -exileapiProtobufVersion=34.1.0.1.20260408183143.85794b77f79d -exileapiGrpcVersion=1.80.0.1.20260408183143.85794b77f79d +exileapiProtobufVersion=34.1.0.1.20260409145750.d435050333a1 +exileapiGrpcVersion=1.80.0.1.20260409145750.d435050333a1 org.gradle.jvmargs=-Xmx4G diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java index 16b0a92..343799c 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java @@ -21,5 +21,10 @@ public interface LogShipper { void shipLogs(List payload); + /** Ship structured log events with level, logger, MDC, and stack trace. */ + default void shipStructuredLogs(List events) { + shipLogs(events.stream().map(e -> e.message).toList()); + } + void stop(); } diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java index b5ef33e..76502a1 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java @@ -18,21 +18,25 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.OutputStreamAppender; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; import java.io.OutputStream; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class MemoryAppender extends OutputStreamAppender { - private static final int MAX_SIZE = 100; + private static final int MAX_SIZE = 1000; private static final long MAX_EVENT_AGE_MS = 3600000; // 1 hour private final BlockingQueue events; - private LogShipper shipper = null; + private volatile LogShipper shipper = null; private final AtomicBoolean isStarted = new AtomicBoolean(false); private Thread cleanupThread; + private Thread shipperThread; public MemoryAppender() { this.events = new ArrayBlockingQueue<>(MAX_SIZE); @@ -110,22 +114,28 @@ protected void append(ILoggingEvent event) { } subAppend(event); + String stackTrace = null; + if (event.getThrowableProxy() != null) { + stackTrace = ThrowableProxyUtil.asString(event.getThrowableProxy()); + } + Map mdc = + event.getMDCPropertyMap() != null ? new HashMap<>(event.getMDCPropertyMap()) : null; + LogEvent logEvent = - new LogEvent(new String(this.encoder.encode(event)), System.currentTimeMillis()); + new LogEvent( + new String(this.encoder.encode(event)), + System.currentTimeMillis(), + event.getLevel() != null ? event.getLevel().toString() : null, + event.getLoggerName(), + event.getThreadName(), + mdc, + stackTrace); if (!events.offer(logEvent)) { // If queue is full, remove oldest and try again events.poll(); events.offer(logEvent); } - - if (shipper != null) { - List eventsToShip = getEventsAsList(); - if (!eventsToShip.isEmpty()) { - shipper.shipLogs(eventsToShip); - events.clear(); - } - } } public List getEventsAsList() { @@ -175,7 +185,10 @@ public List getEventsWithTimestamps() { List snapshot = new ArrayList<>(events); for (LogEvent event : snapshot) { - result.add(new LogEvent(event.message, event.timestamp)); + result.add( + new LogEvent( + event.message, event.timestamp, event.level, event.loggerName, event.threadName, + event.mdc, event.stackTrace)); } return result; @@ -185,22 +198,54 @@ public void enableLogShipper(LogShipper shipper) { addInfo("Log shipper enabled"); if (this.shipper == null) { this.shipper = shipper; - List eventsToShip = getEventsAsList(); - if (!eventsToShip.isEmpty()) { - shipper.shipLogs(eventsToShip); - events.clear(); - } + startShipperThread(); } } public void disableLogShipper() { addInfo("Log shipper disabled"); + stopShipperThread(); if (this.shipper != null) { this.shipper.stop(); this.shipper = null; } } + private void startShipperThread() { + shipperThread = + new Thread( + () -> { + while (isStarted.get() && shipper != null) { + try { + TimeUnit.SECONDS.sleep(10); + drainToShipper(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }); + shipperThread.setDaemon(true); + shipperThread.setName("exile-log-shipper"); + shipperThread.start(); + } + + private void stopShipperThread() { + if (shipperThread != null) { + shipperThread.interrupt(); + shipperThread = null; + } + } + + private void drainToShipper() { + if (shipper == null || events.isEmpty()) return; + List batch = new ArrayList<>(); + events.drainTo(batch); + if (!batch.isEmpty()) { + shipper.shipStructuredLogs(batch); + } + } + public void clearEvents() { events.clear(); } @@ -208,10 +253,31 @@ public void clearEvents() { public static class LogEvent { public final String message; public final long timestamp; + public final String level; + public final String loggerName; + public final String threadName; + public final Map mdc; + public final String stackTrace; public LogEvent(String message, long timestamp) { + this(message, timestamp, null, null, null, null, null); + } + + public LogEvent( + String message, + long timestamp, + String level, + String loggerName, + String threadName, + Map mdc, + String stackTrace) { this.message = message; this.timestamp = timestamp; + this.level = level; + this.loggerName = loggerName; + this.threadName = threadName; + this.mdc = mdc; + this.stackTrace = stackTrace; } } } From 0c75e4f7baaac5c379180edae845613eb0221440 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:32:22 -0600 Subject: [PATCH 30/50] add org_id and config_name to metric attributes, send raw log messages - Defer MetricsManager creation to after first config poll (org_id needed) - Set OTel Resource attributes: exile.org_id, exile.config_name, exile.client_id - Merge resource attributes into every exported MetricDataPoint - Split LogEvent.message (raw) from formattedMessage (encoder pattern) - LogRecord.message now carries the raw message, structured fields carry metadata --- .../main/java/com/tcn/exile/ExileClient.java | 39 ++++++++++---- .../tcn/exile/internal/MetricsManager.java | 54 +++++++++++++------ .../tcn/exile/service/TelemetryService.java | 26 +++++++-- .../tcn/exile/memlogger/MemoryAppender.java | 16 ++++-- 4 files changed, 97 insertions(+), 38 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 3737f80..9c2373e 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -47,7 +47,9 @@ public final class ExileClient implements AutoCloseable { private final ScrubListService scrubListService; private final ConfigService configService; private final JourneyService journeyService; - private final MetricsManager metricsManager; + private final TelemetryService telemetryService; + private final String telemetryClientId; + private volatile MetricsManager metricsManager; private volatile ConfigService.ClientConfiguration lastConfig; private final AtomicBoolean pluginReady = new AtomicBoolean(false); @@ -76,18 +78,20 @@ private ExileClient(Builder builder) { this.scrubListService = services.scrubList(); this.configService = services.config(); this.journeyService = services.journey(); + this.telemetryService = services.telemetry(); - // Telemetry: OTel metrics exported via gRPC to the gate. - var telemetryClientId = builder.clientName + "-" + java.util.UUID.randomUUID().toString().substring(0, 8); - this.metricsManager = new MetricsManager(services.telemetry(), telemetryClientId, workStream::status); - workStream.setDurationRecorder(metricsManager::recordWorkDuration); + // Telemetry client ID (stable across reconnects). + this.telemetryClientId = + builder.clientName + "-" + java.util.UUID.randomUUID().toString().substring(0, 8); - // Telemetry: structured log shipping via gRPC to the gate. + // Structured log shipping starts immediately (doesn't need org_id). var appender = MemoryAppenderInstance.getInstance(); if (appender != null) { - appender.enableLogShipper(new GrpcLogShipper(services.telemetry(), telemetryClientId)); + appender.enableLogShipper(new GrpcLogShipper(telemetryService, telemetryClientId)); } + // MetricsManager is created after first config poll (needs org_id + configName). + this.configPoller = Executors.newSingleThreadScheduledExecutor( r -> { @@ -143,8 +147,18 @@ private void pollConfig() { pluginReady.set(true); } - // Only start WorkStream if plugin has explicitly accepted a config. + // 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 configName. + this.metricsManager = + new MetricsManager( + telemetryService, + telemetryClientId, + newConfig.orgId(), + newConfig.configName(), + workStream::status); + workStream.setDurationRecorder(metricsManager::recordWorkDuration); + log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); workStream.start(); } @@ -196,10 +210,12 @@ public JourneyService journey() { /** * 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. + * 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() { - return metricsManager.meter(); + var mm = metricsManager; + return mm != null ? mm.meter() : null; } @Override @@ -210,7 +226,8 @@ public void close() { if (appender != null) { appender.disableLogShipper(); } - metricsManager.close(); + var mm = metricsManager; + if (mm != null) mm.close(); workStream.close(); ChannelFactory.shutdown(serviceChannel); } diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 38d560e..698fb1d 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -2,10 +2,13 @@ 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.Meter; import io.opentelemetry.sdk.metrics.SdkMeterProvider; import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; import java.lang.management.ManagementFactory; import java.time.Duration; import java.util.function.Supplier; @@ -24,16 +27,35 @@ public final class MetricsManager implements AutoCloseable { private final Meter meter; private final DoubleHistogram workDuration; + /** + * @param telemetryService gRPC stub for reporting metrics + * @param clientId unique client identifier + * @param orgId organization ID (from config poll) + * @param configName exile certificate/config name (from config poll) + * @param statusSupplier supplies current WorkStream status snapshot + */ public MetricsManager( TelemetryService telemetryService, String clientId, + String orgId, + String configName, Supplier statusSupplier) { var exporter = new GrpcMetricExporter(telemetryService, clientId); var reader = PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build(); - this.meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + var resource = + Resource.getDefault() + .merge( + Resource.create( + Attributes.of( + AttributeKey.stringKey("exile.org_id"), orgId, + AttributeKey.stringKey("exile.config_name"), configName, + AttributeKey.stringKey("exile.client_id"), clientId))); + + this.meterProvider = + SdkMeterProvider.builder().setResource(resource).registerMetricReader(reader).build(); this.meter = meterProvider.get("com.tcn.exile.sati"); // --- Built-in instruments --- @@ -43,22 +65,19 @@ public MetricsManager( .counterBuilder("exile.work.completed") .setDescription("Total work items completed since start") .setUnit("1") - .buildWithCallback( - obs -> obs.record(statusSupplier.get().completedTotal())); + .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())); + .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())); + .buildWithCallback(obs -> obs.record(statusSupplier.get().reconnectAttempts())); // WorkStream gauges meter @@ -66,16 +85,15 @@ public MetricsManager( .ofLongs() .setDescription("Work items currently being processed") .setUnit("1") - .buildWithCallback( - obs -> obs.record(statusSupplier.get().inflight())); + .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)") + .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())); + .buildWithCallback(obs -> obs.record(statusSupplier.get().phase().ordinal())); // JVM gauges var memoryBean = ManagementFactory.getMemoryMXBean(); @@ -86,16 +104,14 @@ public MetricsManager( .ofLongs() .setDescription("JVM heap memory used") .setUnit("bytes") - .buildWithCallback( - obs -> obs.record(memoryBean.getHeapMemoryUsage().getUsed())); + .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())); + .buildWithCallback(obs -> obs.record(threadBean.getThreadCount())); // Work duration histogram (recorded externally via recordWorkDuration) this.workDuration = @@ -105,7 +121,11 @@ public MetricsManager( .setUnit("s") .build(); - log.info("MetricsManager initialized (export interval=60s, clientId={})", clientId); + log.info( + "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, configName={})", + clientId, + orgId, + configName); } /** The OTel Meter for plugin developers to create custom instruments. */ diff --git a/core/src/main/java/com/tcn/exile/service/TelemetryService.java b/core/src/main/java/com/tcn/exile/service/TelemetryService.java index 900c9b9..b95b061 100644 --- a/core/src/main/java/com/tcn/exile/service/TelemetryService.java +++ b/core/src/main/java/com/tcn/exile/service/TelemetryService.java @@ -29,17 +29,20 @@ public int reportMetrics(String clientId, Collection 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(attributesToMap(point.getAttributes())) + .putAllAttributes(attrs) .setTime(toTimestamp(point.getEpochNanos())) .setDoubleValue(point.getValue()) .build()); @@ -47,13 +50,14 @@ public int reportMetrics(String clientId, Collection metrics) { } 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(attributesToMap(point.getAttributes())) + .putAllAttributes(attrs) .setTime(toTimestamp(point.getEpochNanos())) .setDoubleValue(point.getValue()) .build()); @@ -61,13 +65,14 @@ public int reportMetrics(String clientId, Collection metrics) { } 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(attributesToMap(point.getAttributes())) + .putAllAttributes(attrs) .setTime(toTimestamp(point.getEpochNanos())) .setIntValue(point.getValue()) .build()); @@ -75,13 +80,14 @@ public int reportMetrics(String clientId, Collection metrics) { } 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(attributesToMap(point.getAttributes())) + .putAllAttributes(attrs) .setTime(toTimestamp(point.getEpochNanos())) .setDoubleValue(point.getValue()) .build()); @@ -89,6 +95,7 @@ public int reportMetrics(String clientId, Collection metrics) { } case HISTOGRAM -> { for (var point : metric.getHistogramData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); var hv = HistogramValue.newBuilder() .setCount(point.getCount()) @@ -103,7 +110,7 @@ public int reportMetrics(String clientId, Collection metrics) { .setDescription(description) .setUnit(unit) .setType(MetricType.METRIC_TYPE_HISTOGRAM) - .putAllAttributes(attributesToMap(point.getAttributes())) + .putAllAttributes(attrs) .setTime(toTimestamp(point.getEpochNanos())) .setHistogramValue(hv) .build()); @@ -131,6 +138,15 @@ private static java.util.Map attributesToMap( 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()) diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java index 76502a1..e3441a8 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java @@ -123,6 +123,7 @@ protected void append(ILoggingEvent event) { LogEvent logEvent = new LogEvent( + event.getFormattedMessage(), new String(this.encoder.encode(event)), System.currentTimeMillis(), event.getLevel() != null ? event.getLevel().toString() : null, @@ -145,7 +146,7 @@ public List getEventsAsList() { List snapshot = new ArrayList<>(events); for (LogEvent event : snapshot) { - result.add(event.message); + result.add(event.formattedMessage != null ? event.formattedMessage : event.message); } return result; @@ -166,7 +167,7 @@ public List getEventsInTimeRange(long startTimeMs, long endTimeMs) { for (LogEvent event : snapshot) { if (event.timestamp >= startTimeMs && event.timestamp <= endTimeMs) { - result.add(event.message); + result.add(event.formattedMessage != null ? event.formattedMessage : event.message); } } @@ -187,8 +188,8 @@ public List getEventsWithTimestamps() { for (LogEvent event : snapshot) { result.add( new LogEvent( - event.message, event.timestamp, event.level, event.loggerName, event.threadName, - event.mdc, event.stackTrace)); + event.message, event.formattedMessage, event.timestamp, event.level, + event.loggerName, event.threadName, event.mdc, event.stackTrace)); } return result; @@ -251,7 +252,10 @@ public void clearEvents() { } public static class LogEvent { + /** Raw log message (no pattern formatting). */ public final String message; + /** Formatted log line from the encoder pattern (for display/legacy). */ + public final String formattedMessage; public final long timestamp; public final String level; public final String loggerName; @@ -260,11 +264,12 @@ public static class LogEvent { public final String stackTrace; public LogEvent(String message, long timestamp) { - this(message, timestamp, null, null, null, null, null); + this(message, null, timestamp, null, null, null, null, null); } public LogEvent( String message, + String formattedMessage, long timestamp, String level, String loggerName, @@ -272,6 +277,7 @@ public LogEvent( Map mdc, String stackTrace) { this.message = message; + this.formattedMessage = formattedMessage; this.timestamp = timestamp; this.level = level; this.loggerName = loggerName; From c600891784b24caef61ec9230bcf9b98597d789a Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:34:42 -0600 Subject: [PATCH 31/50] capture trace_id and span_id in log records at append time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TraceContextExtractor interface to MemoryAppender (pluggable, OTel-free) - ExileClient wires it to Span.current() so each LogEvent captures the active trace/span IDs at the moment the log event is produced - GrpcLogShipper reads traceId/spanId from LogEvent instead of Span.current() (shipper thread has no active span — must capture at source) --- .../main/java/com/tcn/exile/ExileClient.java | 18 +++++++++ .../tcn/exile/internal/GrpcLogShipper.java | 10 +---- .../tcn/exile/memlogger/MemoryAppender.java | 40 +++++++++++++++++-- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 9c2373e..54f85db 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -5,8 +5,10 @@ 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.opentelemetry.api.trace.Span; import io.opentelemetry.api.metrics.Meter; import io.grpc.ManagedChannel; import java.time.Duration; @@ -84,6 +86,22 @@ private ExileClient(Builder builder) { 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) { diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java index 62a9a0e..98820ed 100644 --- a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java +++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java @@ -6,7 +6,6 @@ import com.tcn.exile.memlogger.LogShipper; import com.tcn.exile.memlogger.MemoryAppender; import com.tcn.exile.service.TelemetryService; -import io.opentelemetry.api.trace.Span; import java.util.ArrayList; import java.util.List; import org.slf4j.Logger; @@ -59,13 +58,8 @@ public void shipStructuredLogs(List events) { if (event.threadName != null) builder.setThreadName(event.threadName); if (event.stackTrace != null) builder.setStackTrace(event.stackTrace); if (event.mdc != null) builder.putAllMdc(event.mdc); - - // Attach trace context from the current span if available. - var spanContext = Span.current().getSpanContext(); - if (spanContext.isValid()) { - builder.setTraceId(spanContext.getTraceId()); - builder.setSpanId(spanContext.getSpanId()); - } + if (event.traceId != null) builder.setTraceId(event.traceId); + if (event.spanId != null) builder.setSpanId(event.spanId); records.add(builder.build()); } diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java index e3441a8..4f474cf 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java @@ -38,6 +38,21 @@ public class MemoryAppender extends OutputStreamAppender { private Thread cleanupThread; private Thread shipperThread; + /** + * Pluggable trace context extractor. Called at append time to capture the current trace/span IDs. + * Set from the core module after OTel SDK is initialized. + */ + public interface TraceContextExtractor { + String traceId(); + String spanId(); + } + + private static volatile TraceContextExtractor traceContextExtractor; + + public static void setTraceContextExtractor(TraceContextExtractor extractor) { + traceContextExtractor = extractor; + } + public MemoryAppender() { this.events = new ArrayBlockingQueue<>(MAX_SIZE); } @@ -121,6 +136,14 @@ protected void append(ILoggingEvent event) { Map mdc = event.getMDCPropertyMap() != null ? new HashMap<>(event.getMDCPropertyMap()) : null; + String traceId = null; + String spanId = null; + var extractor = traceContextExtractor; + if (extractor != null) { + traceId = extractor.traceId(); + spanId = extractor.spanId(); + } + LogEvent logEvent = new LogEvent( event.getFormattedMessage(), @@ -130,7 +153,9 @@ protected void append(ILoggingEvent event) { event.getLoggerName(), event.getThreadName(), mdc, - stackTrace); + stackTrace, + traceId, + spanId); if (!events.offer(logEvent)) { // If queue is full, remove oldest and try again @@ -189,7 +214,8 @@ public List getEventsWithTimestamps() { result.add( new LogEvent( event.message, event.formattedMessage, event.timestamp, event.level, - event.loggerName, event.threadName, event.mdc, event.stackTrace)); + event.loggerName, event.threadName, event.mdc, event.stackTrace, + event.traceId, event.spanId)); } return result; @@ -262,9 +288,11 @@ public static class LogEvent { public final String threadName; public final Map mdc; public final String stackTrace; + public final String traceId; + public final String spanId; public LogEvent(String message, long timestamp) { - this(message, null, timestamp, null, null, null, null, null); + this(message, null, timestamp, null, null, null, null, null, null, null); } public LogEvent( @@ -275,7 +303,9 @@ public LogEvent( String loggerName, String threadName, Map mdc, - String stackTrace) { + String stackTrace, + String traceId, + String spanId) { this.message = message; this.formattedMessage = formattedMessage; this.timestamp = timestamp; @@ -284,6 +314,8 @@ public LogEvent( this.threadName = threadName; this.mdc = mdc; this.stackTrace = stackTrace; + this.traceId = traceId; + this.spanId = spanId; } } } From b22ae4a34b408a92c75779280853615a90040627 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:47:10 -0600 Subject: [PATCH 32/50] ship log messages as JSON payload with all structured fields GrpcLogShipper now serializes each log event as a JSON object in the message field: timestamp, level, logger, message, thread, mdc, stackTrace, traceId, spanId. The gate parses this and emits it as messageJson in zerolog output. --- .../tcn/exile/internal/GrpcLogShipper.java | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java index 98820ed..746dc2c 100644 --- a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java +++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java @@ -6,7 +6,11 @@ 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; @@ -50,9 +54,11 @@ public void shipStructuredLogs(List events) { var builder = LogRecord.newBuilder() .setTime( - Timestamp.newBuilder().setSeconds(event.timestamp / 1000).setNanos((int) ((event.timestamp % 1000) * 1_000_000))) + Timestamp.newBuilder() + .setSeconds(event.timestamp / 1000) + .setNanos((int) ((event.timestamp % 1000) * 1_000_000))) .setLevel(mapLevel(event.level)) - .setMessage(event.message != null ? event.message : ""); + .setMessage(toJson(event)); if (event.loggerName != null) builder.setLoggerName(event.loggerName); if (event.threadName != null) builder.setThreadName(event.threadName); @@ -66,6 +72,53 @@ public void shipStructuredLogs(List events) { 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); From 4e7374d708d29ad72e0ebbb9e8ff9445279dc5ef Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:48:37 -0600 Subject: [PATCH 33/50] add OTel tracing spans to work item processing for log trace correlation - Create a span around each processWorkItem() call with work_id and category attributes. All log lines during job/event handling now carry trace_id and span_id via the TraceContextExtractor. - Register SdkTracerProvider + OpenTelemetrySdk globally so GlobalOpenTelemetry.getTracer() returns a real tracer. - Add opentelemetry-sdk-trace dependency. - Exclude LiveBenchmark from default test runs (requires network). --- core/build.gradle | 4 +++- .../tcn/exile/internal/MetricsManager.java | 16 ++++++++++++- .../tcn/exile/internal/WorkStreamClient.java | 23 ++++++++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 77a6d78..62eaaa1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -21,10 +21,11 @@ dependencies { // protobuf runtime api("com.google.protobuf:protobuf-java:${protobufVersion}") - // OpenTelemetry SDK — metrics collection and custom exporter + // 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") // PKCS#1 → PKCS#8 key conversion for mTLS implementation("org.bouncycastle:bcpkix-jdk18on:1.79") @@ -53,5 +54,6 @@ jacocoTestReport { } test { + exclude '**/LiveBenchmark*' finalizedBy jacocoTestReport } diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 698fb1d..2261d5a 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -2,13 +2,16 @@ import com.tcn.exile.StreamStatus; import com.tcn.exile.service.TelemetryService; +import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.DoubleHistogram; 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; @@ -23,6 +26,7 @@ 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; @@ -56,6 +60,16 @@ public MetricsManager( 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(); + + this.openTelemetry = + OpenTelemetrySdk.builder() + .setMeterProvider(meterProvider) + .setTracerProvider(tracerProvider) + .buildAndRegisterGlobal(); + this.meter = meterProvider.get("com.tcn.exile.sati"); // --- Built-in instruments --- @@ -140,7 +154,7 @@ public void recordWorkDuration(double seconds) { @Override public void close() { - meterProvider.close(); + openTelemetry.close(); log.info("MetricsManager shut down"); } } diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index a102557..d401aa5 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -11,6 +11,11 @@ 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.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; import java.time.Instant; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -253,10 +258,23 @@ public void setDurationRecorder(java.util.function.DoubleConsumer recorder) { this.durationRecorder = recorder; } + private static final Tracer tracer = + GlobalOpenTelemetry.getTracer("com.tcn.exile.sati", "1.0.0"); + private void processWorkItem(WorkItem item) { long startNanos = System.nanoTime(); String workId = item.getWorkId(); - try { + String category = + item.getCategory() == WorkCategory.WORK_CATEGORY_JOB ? "job" : "event"; + + Span span = + tracer + .spanBuilder("exile.work." + category) + .setAttribute("exile.work_id", workId) + .setAttribute("exile.work_category", category) + .startSpan(); + + try (Scope ignored = span.makeCurrent()) { if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { var result = dispatchJob(item); send(WorkRequest.newBuilder().setResult(result).build()); @@ -266,6 +284,8 @@ private void processWorkItem(WorkItem item) { } completedTotal.incrementAndGet(); } catch (Exception e) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + span.recordException(e); failedTotal.incrementAndGet(); log.warn("Work item {} failed: {}", workId, e.getMessage()); send( @@ -277,6 +297,7 @@ private void processWorkItem(WorkItem item) { .setError(ErrorResult.newBuilder().setMessage(e.getMessage()))) .build()); } finally { + span.end(); var recorder = durationRecorder; if (recorder != null) { recorder.accept((System.nanoTime() - startNanos) / 1_000_000_000.0); From cdde02eb83de42d333f6e2a48081577ae29873c6 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:50:48 -0600 Subject: [PATCH 34/50] fix GlobalOpenTelemetry.set already called on repeated MetricsManager init Catch IllegalStateException from buildAndRegisterGlobal() and fall back to build() without global registration. Handles multi-tenant and restart scenarios where GlobalOpenTelemetry was already set. --- .../com/tcn/exile/internal/MetricsManager.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 2261d5a..38a0143 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -64,11 +64,19 @@ public MetricsManager( // TracerProvider generates valid trace/span IDs for log correlation. var tracerProvider = SdkTracerProvider.builder().setResource(resource).build(); - this.openTelemetry = + var sdkBuilder = OpenTelemetrySdk.builder() .setMeterProvider(meterProvider) - .setTracerProvider(tracerProvider) - .buildAndRegisterGlobal(); + .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"); From 4d365f594b845fc9365ce2580e6c78cefa45375d Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:00:17 -0600 Subject: [PATCH 35/50] parse trace_parent from WorkItem and create child spans for work processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gate sets trace_parent on a WorkItem, sati now parses the W3C traceparent and creates a child span. All logs during job/event handling carry the upstream trace_id and span_id, enabling end-to-end trace correlation: station → gate → sati. --- .../tcn/exile/internal/WorkStreamClient.java | 35 +++++++++++++++++-- gradle.properties | 4 +-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index d401aa5..afe47c7 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -13,8 +13,13 @@ 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; @@ -261,18 +266,42 @@ public void setDurationRecorder(java.util.function.DoubleConsumer recorder) { private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.tcn.exile.sati", "1.0.0"); + /** 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"; - Span span = + var spanBuilder = tracer .spanBuilder("exile.work." + category) + .setSpanKind(SpanKind.CONSUMER) .setAttribute("exile.work_id", workId) - .setAttribute("exile.work_category", category) - .startSpan(); + .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(); try (Scope ignored = span.makeCurrent()) { if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { diff --git a/gradle.properties b/gradle.properties index c0235ee..5a230f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ grpcVersion=1.80.0 protobufVersion=4.28.3 -exileapiProtobufVersion=34.1.0.1.20260409145750.d435050333a1 -exileapiGrpcVersion=1.80.0.1.20260409145750.d435050333a1 +exileapiProtobufVersion=34.1.0.1.20260409155737.b9aa1a8dba14 +exileapiGrpcVersion=1.80.0.1.20260409155737.b9aa1a8dba14 org.gradle.jvmargs=-Xmx4G From 891f6b9010604d4af96868d101571c146d138908 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:27:48 -0600 Subject: [PATCH 36/50] propagate trace context to async response logs (ResultAccepted, LeaseExpiring, Error) Store SpanContext per work_id in ConcurrentHashMap. When async responses arrive on the gRPC callback thread, temporarily activate the span context so log lines carry the same trace_id/span_id. Cleaned up on RESULT_ACCEPTED (jobs) or after processing (events). --- .../tcn/exile/internal/WorkStreamClient.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index afe47c7..73fca35 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -57,6 +57,10 @@ public final class WorkStreamClient implements AutoCloseable { 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; @@ -226,12 +230,15 @@ private void handleResponse(WorkResponse response) { inflight.incrementAndGet(); workerPool.submit(() -> processWorkItem(response.getWorkItem())); } - case RESULT_ACCEPTED -> - log.debug("Result accepted: {}", response.getResultAccepted().getWorkId()); + 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(); - log.debug( - "Lease expiring for {}, {}s remaining", w.getWorkId(), w.getRemaining().getSeconds()); + withWorkSpan(w.getWorkId(), () -> + log.debug("Lease expiring for {}, {}s remaining", w.getWorkId(), w.getRemaining().getSeconds())); send( WorkRequest.newBuilder() .setExtendLease( @@ -252,7 +259,8 @@ private void handleResponse(WorkResponse response) { case ERROR -> { var err = response.getError(); lastError = err.getCode() + ": " + err.getMessage(); - log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage()); + withWorkSpan(err.getWorkId(), () -> + log.warn("Stream error for {}: {} - {}", err.getWorkId(), err.getCode(), err.getMessage())); } default -> {} } @@ -266,6 +274,18 @@ public void setDurationRecorder(java.util.function.DoubleConsumer 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()) { + action.run(); + } + } 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; @@ -302,6 +322,7 @@ private void processWorkItem(WorkItem item) { } Span span = spanBuilder.startSpan(); + workSpanContexts.put(workId, span.getSpanContext()); try (Scope ignored = span.makeCurrent()) { if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { @@ -327,6 +348,11 @@ private void processWorkItem(WorkItem item) { .build()); } finally { 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); From 2082dc490b57ecdd89dcb34401f570950fec1ff7 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:59:50 -0600 Subject: [PATCH 37/50] show traceId and spanId in logback console output Populate SLF4J MDC with traceId/spanId when processing work items so logback pattern can display them. Update demo logback.xml to include trace context in the console output pattern. --- .../java/com/tcn/exile/internal/WorkStreamClient.java | 10 ++++++++++ demo/src/main/resources/logback.xml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 73fca35..98b20ac 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -12,6 +12,7 @@ import io.grpc.ManagedChannel; import io.grpc.stub.StreamObserver; import io.opentelemetry.api.GlobalOpenTelemetry; +import org.slf4j.MDC; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanKind; @@ -279,7 +280,12 @@ 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(); @@ -325,6 +331,8 @@ private void processWorkItem(WorkItem item) { 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()); @@ -347,6 +355,8 @@ private void processWorkItem(WorkItem item) { .setError(ErrorResult.newBuilder().setMessage(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. diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml index 6ba014d..dfe6419 100644 --- a/demo/src/main/resources/logback.xml +++ b/demo/src/main/resources/logback.xml @@ -1,7 +1,7 @@ - %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg [traceId=%mdc{traceId:-} spanId=%mdc{spanId:-}]%n From 03fd850e9a4dcb127d633862cd28cd29dc2cbc97 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:31:15 -0600 Subject: [PATCH 38/50] hide trace context from log output when not available --- demo/src/main/resources/logback.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml index dfe6419..f3d0ce5 100644 --- a/demo/src/main/resources/logback.xml +++ b/demo/src/main/resources/logback.xml @@ -1,7 +1,7 @@ - %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg [traceId=%mdc{traceId:-} spanId=%mdc{spanId:-}]%n + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%replace( [traceId=%mdc{traceId} spanId=%mdc{spanId}]){' \[traceId= spanId=\]', ''}%n From 64f454d09b098f3669c777eabf59cfc48c485870 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:35:27 -0600 Subject: [PATCH 39/50] add per-method metrics for all plugin methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new OTel instruments: - exile.plugin.calls (counter) — invocations per method, with status - exile.plugin.duration (histogram) — execution time per method Both carry method and status (ok/error) attributes. Covers all 15 job handler methods and 6 event handler methods automatically. --- .../main/java/com/tcn/exile/ExileClient.java | 1 + .../tcn/exile/internal/MetricsManager.java | 30 ++++++++++ .../tcn/exile/internal/WorkStreamClient.java | 59 +++++++++++++++---- 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 54f85db..a54d5a0 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -176,6 +176,7 @@ private void pollConfig() { newConfig.configName(), workStream::status); workStream.setDurationRecorder(metricsManager::recordWorkDuration); + workStream.setMethodRecorder(metricsManager::recordMethodCall); log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); workStream.start(); diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 38a0143..8956c58 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -5,7 +5,10 @@ import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; +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; @@ -30,6 +33,11 @@ public final class MetricsManager implements AutoCloseable { private final SdkMeterProvider meterProvider; private final Meter meter; private final DoubleHistogram workDuration; + private final DoubleHistogram methodDuration; + private final LongCounter methodCalls; + + 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 @@ -143,6 +151,21 @@ public MetricsManager( .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(); + log.info( "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, configName={})", clientId, @@ -160,6 +183,13 @@ public void recordWorkDuration(double seconds) { workDuration.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(); diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 98b20ac..f327a9c 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -75,6 +75,13 @@ public final class WorkStreamClient implements AutoCloseable { private volatile ManagedChannel channel; private volatile Thread streamThread; private volatile java.util.function.DoubleConsumer durationRecorder; + 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, @@ -272,6 +279,11 @@ 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; + } + private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.tcn.exile.sati", "1.0.0"); @@ -378,7 +390,10 @@ private void processWorkItem(WorkItem item) { 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(); @@ -503,22 +518,40 @@ var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId()); } 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 { - 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()); + 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); + } } } From 0c2af364225e934cde68d03b34bafd767cfd451e Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:43:34 -0600 Subject: [PATCH 40/50] aggressive reconnect: faster keepalive, smarter backoff, reconnect metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keepalive: 10s ping / 5s timeout (was 32s/30s) — detect dead connections in 15s instead of 62s - Backoff: 500ms base / 10s max (was 2s/30s) — faster recovery on real failures - Skip backoff entirely on RST_STREAM NO_ERROR (envoy stream recycling) and server-initiated close — reconnect immediately - Add exile.work.reconnect_duration histogram and log reconnect time on every re-registration --- .../main/java/com/tcn/exile/ExileClient.java | 1 + .../java/com/tcn/exile/internal/Backoff.java | 4 +-- .../tcn/exile/internal/ChannelFactory.java | 4 +-- .../tcn/exile/internal/MetricsManager.java | 13 +++++++++ .../tcn/exile/internal/WorkStreamClient.java | 29 +++++++++++++++++-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index a54d5a0..2e23b59 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -177,6 +177,7 @@ private void pollConfig() { workStream::status); workStream.setDurationRecorder(metricsManager::recordWorkDuration); workStream.setMethodRecorder(metricsManager::recordMethodCall); + workStream.setReconnectRecorder(metricsManager::recordReconnectDuration); log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); workStream.start(); diff --git a/core/src/main/java/com/tcn/exile/internal/Backoff.java b/core/src/main/java/com/tcn/exile/internal/Backoff.java index f174cd4..17b9dc1 100644 --- a/core/src/main/java/com/tcn/exile/internal/Backoff.java +++ b/core/src/main/java/com/tcn/exile/internal/Backoff.java @@ -5,8 +5,8 @@ /** Exponential backoff with jitter for reconnection. */ public final class Backoff { - private static final long BASE_MS = 2_000; - private static final long MAX_MS = 30_000; + 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; diff --git a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java index 960c27f..94f37d4 100644 --- a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java +++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java @@ -40,8 +40,8 @@ public static ManagedChannel create(ExileConfig config) { return NettyChannelBuilder.forAddress( new InetSocketAddress(config.apiHostname(), config.apiPort())) .sslContext(sslContext) - .keepAliveTime(32, TimeUnit.SECONDS) - .keepAliveTimeout(30, TimeUnit.SECONDS) + .keepAliveTime(10, TimeUnit.SECONDS) + .keepAliveTimeout(5, TimeUnit.SECONDS) .keepAliveWithoutCalls(true) .idleTimeout(30, TimeUnit.MINUTES) .flowControlWindow(4 * 1024 * 1024) // 4MB — match envoy upstream window diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 8956c58..8818360 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -35,6 +35,7 @@ public final class MetricsManager implements AutoCloseable { 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"); @@ -166,6 +167,13 @@ public MetricsManager( .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={}, configName={})", clientId, @@ -183,6 +191,11 @@ 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"); diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index f327a9c..88d144e 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -75,6 +75,8 @@ public final class WorkStreamClient implements AutoCloseable { 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). */ @@ -144,11 +146,16 @@ private void reconnectLoop() { Thread.currentThread().interrupt(); break; } catch (Exception e) { - backoff.recordFailure(); + if (lastDisconnectGraceful) { + backoff.reset(); + } else { + 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()); } } @@ -176,6 +183,9 @@ 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()) { @@ -191,6 +201,7 @@ public void onError(Throwable t) { public void onCompleted() { lastDisconnect = Instant.now(); connectedSince = null; + lastDisconnectGraceful = true; log.info("Stream completed by server"); latch.countDown(); } @@ -223,7 +234,16 @@ private void handleResponse(WorkResponse response) { case REGISTERED -> { var reg = response.getRegistered(); clientId = reg.getClientId(); - connectedSince = Instant.now(); + 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()); + } + connectedSince = now; phase = Phase.ACTIVE; log.info( "Registered as {} (heartbeat={}s, lease={}s, max_inflight={})", @@ -284,6 +304,11 @@ 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"); From 07d8bed4b92ade2abf8abee3a73635c8cb5f52f8 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:59:47 -0600 Subject: [PATCH 41/50] fix reconnect storm: apply backoff on UNAVAILABLE errors runStream() returns normally after onError (via latch), so backoff was always reset. Now only reset backoff when lastDisconnectGraceful is true (RST_STREAM NO_ERROR). UNAVAILABLE and other real errors trigger exponential backoff (500ms, 1s, 2s, ... up to 10s). --- .../com/tcn/exile/internal/WorkStreamClient.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 88d144e..48bbe2a 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -141,16 +141,19 @@ private void reconnectLoop() { log.info( "Connecting to {}:{} (attempt #{})", config.apiHostname(), config.apiPort(), attempt); runStream(); - backoff.reset(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } catch (Exception e) { + // 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; From 0c7897d5d1d87f75429287face67854647e88ae3 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:20:33 -0600 Subject: [PATCH 42/50] fix reconnect duration: clear lastDisconnect after recording to avoid cumulative growth --- core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 48bbe2a..04bece8 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -245,6 +245,7 @@ private void handleResponse(WorkResponse response) { 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; From 99b74469dc5df248499e99befab8f1e6b0f642ce Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:33:09 -0600 Subject: [PATCH 43/50] use certificate_name from config file for metric grouping Parse certificate_name from the disk config file, add to ExileConfig, and pass as OTel resource attribute exile.certificate_name. Replaces exile.config_name (from gate poll) for metric label grouping. --- .../java/com/tcn/exile/config/ConfigParser.java | 3 +++ core/src/main/java/com/tcn/exile/ExileClient.java | 4 ++-- core/src/main/java/com/tcn/exile/ExileConfig.java | 13 +++++++++++++ .../java/com/tcn/exile/internal/MetricsManager.java | 10 +++++----- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/config/src/main/java/com/tcn/exile/config/ConfigParser.java b/config/src/main/java/com/tcn/exile/config/ConfigParser.java index 7bd46a9..50a108f 100644 --- a/config/src/main/java/com/tcn/exile/config/ConfigParser.java +++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java @@ -63,6 +63,9 @@ public static Optional parse(byte[] raw) { 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; diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 2e23b59..540161f 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -167,13 +167,13 @@ private void pollConfig() { // 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 configName. + // Initialize MetricsManager now that we have org_id and certificate_name. this.metricsManager = new MetricsManager( telemetryService, telemetryClientId, newConfig.orgId(), - newConfig.configName(), + config.certificateName(), workStream::status); workStream.setDurationRecorder(metricsManager::recordWorkDuration); workStream.setMethodRecorder(metricsManager::recordMethodCall); diff --git a/core/src/main/java/com/tcn/exile/ExileConfig.java b/core/src/main/java/com/tcn/exile/ExileConfig.java index d238d7b..5c10a65 100644 --- a/core/src/main/java/com/tcn/exile/ExileConfig.java +++ b/core/src/main/java/com/tcn/exile/ExileConfig.java @@ -18,6 +18,7 @@ public final class ExileConfig { private final String privateKey; private final String apiHostname; private final int apiPort; + private final String certificateName; // Lazily derived from certificate. private volatile String org; @@ -28,6 +29,7 @@ private ExileConfig(Builder builder) { 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() { @@ -50,6 +52,11 @@ 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; @@ -95,6 +102,7 @@ public static final class Builder { private String privateKey; private String apiHostname; private int apiPort; + private String certificateName; private Builder() {} @@ -118,6 +126,11 @@ public Builder apiHostname(String apiHostname) { return this; } + public Builder certificateName(String certificateName) { + this.certificateName = certificateName; + return this; + } + public Builder apiPort(int apiPort) { this.apiPort = apiPort; return this; diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 8818360..52f350e 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -44,14 +44,14 @@ public final class MetricsManager implements AutoCloseable { * @param telemetryService gRPC stub for reporting metrics * @param clientId unique client identifier * @param orgId organization ID (from config poll) - * @param configName exile certificate/config name (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 configName, + String certificateName, Supplier statusSupplier) { var exporter = new GrpcMetricExporter(telemetryService, clientId); @@ -64,7 +64,7 @@ public MetricsManager( Resource.create( Attributes.of( AttributeKey.stringKey("exile.org_id"), orgId, - AttributeKey.stringKey("exile.config_name"), configName, + AttributeKey.stringKey("exile.certificate_name"), certificateName, AttributeKey.stringKey("exile.client_id"), clientId))); this.meterProvider = @@ -175,10 +175,10 @@ public MetricsManager( .build(); log.info( - "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, configName={})", + "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, certificateName={})", clientId, orgId, - configName); + certificateName); } /** The OTel Meter for plugin developers to create custom instruments. */ From af8b0d3e994b5071ac89c7930695f558ce513cea Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:35:38 -0600 Subject: [PATCH 44/50] periodic pull every 2s: sati tells gate its available capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace pull-on-completion with a periodic pull thread that sends Pull(maxConcurrency - inflight) every 2s. The gate dispatches events only when Pull arrives — no server-side capacity tracking. --- .../tcn/exile/internal/WorkStreamClient.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 04bece8..e850b75 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -223,8 +223,25 @@ public void onCompleted() { .addAllCapabilities(capabilities)) .build()); + // Periodically send Pull to tell the gate how many items we can handle. + var pullThread = Thread.ofPlatform().name("exile-pull-ticker").daemon(true).start(() -> { + while (!Thread.currentThread().isInterrupted() && phase == Phase.ACTIVE) { + try { + Thread.sleep(2000); + int available = maxConcurrency - inflight.get(); + if (available > 0 && phase == Phase.ACTIVE) { + pull(available); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }); + // Wait until stream ends. latch.await(); + pullThread.interrupt(); } finally { requestObserver.set(null); inflight.set(0); @@ -408,12 +425,8 @@ private void processWorkItem(WorkItem item) { if (recorder != null) { recorder.accept((System.nanoTime() - startNanos) / 1_000_000_000.0); } - int remaining = inflight.decrementAndGet(); - // Pull enough to fill capacity. Avoids 1-at-a-time round-trips over a 32ms RTT. - int available = maxConcurrency - remaining; - if (available > 0) { - pull(available); - } + inflight.decrementAndGet(); + // Periodic pull thread handles capacity signaling. } } From 02104562d7a80db7c4b6b5d3038736cf197e3921 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:16:47 -0600 Subject: [PATCH 45/50] send Nack instead of Result(error) for failed events --- .../tcn/exile/internal/WorkStreamClient.java | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index e850b75..07e5b20 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -404,14 +404,21 @@ private void processWorkItem(WorkItem item) { span.recordException(e); failedTotal.incrementAndGet(); log.warn("Work item {} failed: {}", workId, e.getMessage()); - send( - WorkRequest.newBuilder() - .setResult( - Result.newBuilder() - .setWorkId(workId) - .setFinal(true) - .setError(ErrorResult.newBuilder().setMessage(e.getMessage()))) - .build()); + 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"); From a075ea52c66de65d293b68cac5d019003d9de94d Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:43:49 -0600 Subject: [PATCH 46/50] =?UTF-8?q?increase=20throughput:=20maxConcurrency?= =?UTF-8?q?=205=E2=86=9220,=20pull=20interval=202s=E2=86=921s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 concurrent work items with 1s pull interval allows ~20 items/sec from periodic pulls alone. Virtual thread parallelism multiplies this further as completed items free capacity before the next pull. --- .../src/main/java/com/tcn/exile/config/ExileClientManager.java | 2 +- core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java index 1ae9648..999fdf2 100644 --- a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -184,7 +184,7 @@ public static Builder builder() { public static final class Builder { private String clientName = "sati"; private String clientVersion = "unknown"; - private int maxConcurrency = 5; + private int maxConcurrency = 20; private Plugin plugin; private List watchDirs; private int certRotationHours = 1; diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index 07e5b20..e2cf74e 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -227,7 +227,7 @@ public void onCompleted() { var pullThread = Thread.ofPlatform().name("exile-pull-ticker").daemon(true).start(() -> { while (!Thread.currentThread().isInterrupted() && phase == Phase.ACTIVE) { try { - Thread.sleep(2000); + Thread.sleep(1000); int available = maxConcurrency - inflight.get(); if (available > 0 && phase == Phase.ACTIVE) { pull(available); From 29d11710fa829151038f9690e72da51840952061 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:00:53 -0600 Subject: [PATCH 47/50] increase maxConcurrency from 20 to 100 for higher event throughput --- .../src/main/java/com/tcn/exile/config/ExileClientManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java index 999fdf2..177ade2 100644 --- a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -184,7 +184,7 @@ public static Builder builder() { public static final class Builder { private String clientName = "sati"; private String clientVersion = "unknown"; - private int maxConcurrency = 20; + private int maxConcurrency = 100; private Plugin plugin; private List watchDirs; private int certRotationHours = 1; From cbfd0fbab236f837401c4ad2df279172e12ebad7 Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:11:47 -0600 Subject: [PATCH 48/50] single Pull(MAX_VALUE) on registration, no periodic pulling Sati sends one Pull after registration telling the gate to push continuously. gRPC HTTP/2 flow control handles backpressure. Removes the periodic pull thread entirely. --- .../tcn/exile/internal/WorkStreamClient.java | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index e2cf74e..a304ea2 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -223,25 +223,8 @@ public void onCompleted() { .addAllCapabilities(capabilities)) .build()); - // Periodically send Pull to tell the gate how many items we can handle. - var pullThread = Thread.ofPlatform().name("exile-pull-ticker").daemon(true).start(() -> { - while (!Thread.currentThread().isInterrupted() && phase == Phase.ACTIVE) { - try { - Thread.sleep(1000); - int available = maxConcurrency - inflight.get(); - if (available > 0 && phase == Phase.ACTIVE) { - pull(available); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - }); - // Wait until stream ends. latch.await(); - pullThread.interrupt(); } finally { requestObserver.set(null); inflight.set(0); @@ -272,8 +255,9 @@ private void handleResponse(WorkResponse response) { reg.getHeartbeatInterval().getSeconds(), reg.getDefaultLease().getSeconds(), reg.getMaxInflight()); - // Initial pull now that we're registered. - pull(maxConcurrency); + // 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(); From 02c3e22b291adf04ec7b84740027f04397fa89dd Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:24:15 -0600 Subject: [PATCH 49/50] apply spotless formatting --- .../main/java/com/tcn/exile/ExileClient.java | 4 +- .../tcn/exile/internal/GrpcLogShipper.java | 4 +- .../tcn/exile/internal/MetricsManager.java | 6 +- .../tcn/exile/internal/WorkStreamClient.java | 289 +++++++++--------- .../tcn/exile/service/TelemetryService.java | 4 +- .../com/tcn/exile/internal/LiveBenchmark.java | 115 ++++--- .../tcn/exile/internal/StreamBenchmark.java | 18 +- .../tcn/exile/memlogger/MemoryAppender.java | 18 +- 8 files changed, 247 insertions(+), 211 deletions(-) diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java index 540161f..bfa0b60 100644 --- a/core/src/main/java/com/tcn/exile/ExileClient.java +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -8,9 +8,9 @@ import com.tcn.exile.memlogger.MemoryAppender; import com.tcn.exile.memlogger.MemoryAppenderInstance; import com.tcn.exile.service.*; -import io.opentelemetry.api.trace.Span; -import io.opentelemetry.api.metrics.Meter; 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; diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java index 746dc2c..e8c1c0d 100644 --- a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java +++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java @@ -16,8 +16,8 @@ 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. + * 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 { diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java index 52f350e..1848a0f 100644 --- a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -2,9 +2,6 @@ import com.tcn.exile.StreamStatus; import com.tcn.exile.service.TelemetryService; -import io.opentelemetry.api.GlobalOpenTelemetry; -import io.opentelemetry.api.common.AttributeKey; -import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.DoubleHistogram; @@ -55,8 +52,7 @@ public MetricsManager( Supplier statusSupplier) { var exporter = new GrpcMetricExporter(telemetryService, clientId); - var reader = - PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build(); + var reader = PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build(); var resource = Resource.getDefault() diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java index a304ea2..90b3b7c 100644 --- a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -12,7 +12,6 @@ import io.grpc.ManagedChannel; import io.grpc.stub.StreamObserver; import io.opentelemetry.api.GlobalOpenTelemetry; -import org.slf4j.MDC; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanKind; @@ -33,6 +32,7 @@ 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. @@ -58,7 +58,8 @@ public final class WorkStreamClient implements AutoCloseable { 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. + // 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<>(); @@ -188,7 +189,8 @@ public void onError(Throwable t) { 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"); + 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()) { @@ -244,7 +246,8 @@ private void handleResponse(WorkResponse response) { 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()); + log.info( + "Reconnected in {}ms", java.time.Duration.between(disconnectTime, now).toMillis()); lastDisconnect = null; } connectedSince = now; @@ -270,8 +273,13 @@ private void handleResponse(WorkResponse response) { } case LEASE_EXPIRING -> { var w = response.getLeaseExpiring(); - withWorkSpan(w.getWorkId(), () -> - log.debug("Lease expiring for {}, {}s remaining", w.getWorkId(), w.getRemaining().getSeconds())); + withWorkSpan( + w.getWorkId(), + () -> + log.debug( + "Lease expiring for {}, {}s remaining", + w.getWorkId(), + w.getRemaining().getSeconds())); send( WorkRequest.newBuilder() .setExtendLease( @@ -292,8 +300,14 @@ private void handleResponse(WorkResponse response) { 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())); + withWorkSpan( + err.getWorkId(), + () -> + log.warn( + "Stream error for {}: {} - {}", + err.getWorkId(), + err.getCode(), + err.getMessage())); } default -> {} } @@ -314,8 +328,7 @@ 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"); + 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) { @@ -353,8 +366,7 @@ private static SpanContext parseTraceParent(String traceParent) { private void processWorkItem(WorkItem item) { long startNanos = System.nanoTime(); String workId = item.getWorkId(); - String category = - item.getCategory() == WorkCategory.WORK_CATEGORY_JOB ? "job" : "event"; + String category = item.getCategory() == WorkCategory.WORK_CATEGORY_JOB ? "job" : "event"; var spanBuilder = tracer @@ -427,132 +439,132 @@ private Result.Builder dispatchJob(WorkItem item) throws Exception { 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())); + switch (item.getTaskCase()) { + case LIST_POOLS -> { + var pools = jobHandler.listPools(); + b.setListPools( + ListPoolsResult.newBuilder() + .addAllPools(pools.stream().map(ProtoConverter::fromPool).toList())); } - b.setListTenantLogs(rb); - } - case SET_LOG_LEVEL -> { - var task = item.getSetLogLevel(); - jobHandler.setLogLevel(task.getLoggerName(), task.getLevel()); - b.setSetLogLevel(SetLogLevelResult.getDefaultInstance()); + 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()); } - default -> throw new UnsupportedOperationException("Unknown job: " + item.getTaskCase()); - } - methodSuccess = true; - return b; + methodSuccess = true; + return b; } finally { var mr = methodRecorder; if (mr != null) { @@ -605,7 +617,10 @@ private void send(WorkRequest request) { // instead of waiting for the next Recv to fail. var current = requestObserver.getAndSet(null); if (current != null) { - try { current.onError(e); } catch (Exception ignored) {} + try { + current.onError(e); + } catch (Exception ignored) { + } } } } diff --git a/core/src/main/java/com/tcn/exile/service/TelemetryService.java b/core/src/main/java/com/tcn/exile/service/TelemetryService.java index b95b061..e865b69 100644 --- a/core/src/main/java/com/tcn/exile/service/TelemetryService.java +++ b/core/src/main/java/com/tcn/exile/service/TelemetryService.java @@ -21,9 +21,7 @@ public final class TelemetryService { public int reportMetrics(String clientId, Collection metrics) { var now = Instant.now(); var builder = - ReportMetricsRequest.newBuilder() - .setClientId(clientId) - .setCollectionTime(toTimestamp(now)); + ReportMetricsRequest.newBuilder().setClientId(clientId).setCollectionTime(toTimestamp(now)); for (var metric : metrics) { var name = metric.getName(); diff --git a/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java index aa44a53..c9a5f0a 100644 --- a/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java +++ b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java @@ -8,6 +8,7 @@ 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; @@ -17,24 +18,27 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import io.grpc.stub.StreamObserver; 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. + * 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"); + 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"); + 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; @@ -46,13 +50,14 @@ static void connect() throws Exception { 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(); + 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()); @@ -71,14 +76,15 @@ private static String extractJsonString(String json, String key) { 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); - }); + 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); } @@ -98,8 +104,11 @@ private static int parsePort(String endpoint) { 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 */ } + try { + return Integer.parseInt(endpoint.substring(colon + 1)); + } catch (NumberFormatException e) { + /* fall through */ + } } return 443; } @@ -123,11 +132,7 @@ void pingLatency() { for (int i = 0; i < iterations; i++) { long start = System.nanoTime(); - var resp = - stub.ping( - PingRequest.newBuilder() - .setClientTime(toTimestamp(start)) - .build()); + var resp = stub.ping(PingRequest.newBuilder().setClientTime(toTimestamp(start)).build()); long elapsed = System.nanoTime() - start; times.add(elapsed); } @@ -137,10 +142,16 @@ void pingLatency() { 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); + 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 @@ -160,10 +171,7 @@ void pingWithPayload() { for (int i = 0; i < iterations; i++) { long start = System.nanoTime(); stub.ping( - PingRequest.newBuilder() - .setClientTime(toTimestamp(start)) - .setPayload(payload) - .build()); + PingRequest.newBuilder().setClientTime(toTimestamp(start)).setPayload(payload).build()); long elapsed = System.nanoTime() - start; times.add(elapsed); } @@ -173,10 +181,16 @@ void pingWithPayload() { 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); + 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 @@ -213,7 +227,8 @@ void streamFlowControlled() throws Exception { var result = runStreamBenchmark(stub, 0, maxMessages, batchSize); - System.out.println("=== LIVE FLOW-CONTROLLED (" + maxMessages + " msgs, batch=" + batchSize + ") ==="); + System.out.println( + "=== LIVE FLOW-CONTROLLED (" + maxMessages + " msgs, batch=" + batchSize + ") ==="); printStreamStats(result); } @@ -282,7 +297,10 @@ public void onCompleted() { 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) {} + try { + requestObserver.onCompleted(); + } catch (Exception ignored) { + } Thread.sleep(200); var stats = statsRef.get(); @@ -290,13 +308,14 @@ public void onCompleted() { // 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(); + 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"); diff --git a/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java index adee0e0..424677a 100644 --- a/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java +++ b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java @@ -5,7 +5,6 @@ import build.buf.gen.tcnapi.exile.gate.v3.*; import com.google.protobuf.ByteString; import com.google.protobuf.Timestamp; -import io.grpc.ManagedChannel; import io.grpc.Server; import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; @@ -84,7 +83,10 @@ public void onCompleted() { requestObserver.onNext( BenchmarkRequest.newBuilder() .setStart( - StartBenchmark.newBuilder().setPayloadSize(0).setMaxMessages(maxMessages).setBatchSize(0)) + StartBenchmark.newBuilder() + .setPayloadSize(0) + .setMaxMessages(maxMessages) + .setBatchSize(0)) .build()); assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out waiting for benchmark to complete"); @@ -92,7 +94,8 @@ public void onCompleted() { var stats = statsRef.get(); assertNotNull(stats, "Should have received stats"); - System.out.println("=== UNLIMITED THROUGHPUT (" + maxMessages + " messages, 0 byte payload) ==="); + 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()); @@ -233,11 +236,7 @@ public void onCompleted() { assertNotNull(stats); System.out.println( - "=== FLOW-CONTROLLED (" - + maxMessages - + " messages, batch_size=" - + batchSize - + ") ==="); + "=== 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()); @@ -332,8 +331,7 @@ void benchmarkPingWithPayload() throws Exception { * In-process BenchmarkService that implements the streaming and ping protocols for local * benchmarking. */ - static class InProcessBenchmarkService - extends BenchmarkServiceGrpc.BenchmarkServiceImplBase { + static class InProcessBenchmarkService extends BenchmarkServiceGrpc.BenchmarkServiceImplBase { @Override public StreamObserver streamBenchmark( diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java index 4f474cf..84b8625 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java @@ -17,8 +17,8 @@ package com.tcn.exile.memlogger; import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.OutputStreamAppender; import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.OutputStreamAppender; import java.io.OutputStream; import java.util.ArrayList; import java.util.HashMap; @@ -44,6 +44,7 @@ public class MemoryAppender extends OutputStreamAppender { */ public interface TraceContextExtractor { String traceId(); + String spanId(); } @@ -213,9 +214,16 @@ public List getEventsWithTimestamps() { for (LogEvent event : snapshot) { result.add( new LogEvent( - event.message, event.formattedMessage, event.timestamp, event.level, - event.loggerName, event.threadName, event.mdc, event.stackTrace, - event.traceId, event.spanId)); + event.message, + event.formattedMessage, + event.timestamp, + event.level, + event.loggerName, + event.threadName, + event.mdc, + event.stackTrace, + event.traceId, + event.spanId)); } return result; @@ -280,8 +288,10 @@ public void clearEvents() { public static class LogEvent { /** Raw log message (no pattern formatting). */ public final String message; + /** Formatted log line from the encoder pattern (for display/legacy). */ public final String formattedMessage; + public final long timestamp; public final String level; public final String loggerName; From 45c5a958f0dadaedd7eb7046a4e0ab434ff4edab Mon Sep 17 00:00:00 2001 From: Florin Stan <597933+namtzigla@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:26:00 -0600 Subject: [PATCH 50/50] fix BackoffTest: update expected values for 500ms base / 10s max --- .../src/test/java/com/tcn/exile/internal/BackoffTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/test/java/com/tcn/exile/internal/BackoffTest.java b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java index dfda02d..e93bb1e 100644 --- a/core/src/test/java/com/tcn/exile/internal/BackoffTest.java +++ b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java @@ -17,15 +17,15 @@ void delayIncreasesExponentially() { var b = new Backoff(); b.recordFailure(); long d1 = b.nextDelayMs(); - assertTrue(d1 >= 1600 && d1 <= 2400, "First failure ~2s, got " + d1); + assertTrue(d1 >= 400 && d1 <= 600, "First failure ~500ms, got " + d1); b.recordFailure(); long d2 = b.nextDelayMs(); - assertTrue(d2 >= 3200 && d2 <= 4800, "Second failure ~4s, got " + d2); + assertTrue(d2 >= 800 && d2 <= 1200, "Second failure ~1s, got " + d2); b.recordFailure(); long d3 = b.nextDelayMs(); - assertTrue(d3 >= 6400 && d3 <= 9600, "Third failure ~8s, got " + d3); + assertTrue(d3 >= 1600 && d3 <= 2400, "Third failure ~2s, got " + d3); } @Test @@ -33,7 +33,7 @@ void delayCapsAtMax() { var b = new Backoff(); for (int i = 0; i < 20; i++) b.recordFailure(); long d = b.nextDelayMs(); - assertTrue(d <= 30_000, "Should cap at 30s, got " + d); + assertTrue(d <= 10_000, "Should cap at 10s, got " + d); } @Test