fix(server): event capturing and necessary logic for smoke test.
This commit is contained in:
@@ -15,9 +15,21 @@ dependencies {
|
|||||||
implementation project(':core:kernel')
|
implementation project(':core:kernel')
|
||||||
implementation project(':core:inference')
|
implementation project(':core:inference')
|
||||||
implementation project(':core:transitions')
|
implementation project(':core:transitions')
|
||||||
|
implementation project(':core:context')
|
||||||
|
implementation project(':core:validation')
|
||||||
|
implementation project(':core:risk')
|
||||||
|
implementation project(':core:artifacts')
|
||||||
|
implementation project(':infrastructure')
|
||||||
|
implementation project(':infrastructure:workflow')
|
||||||
|
implementation project(':infrastructure:persistence')
|
||||||
|
implementation project(':infrastructure:inference')
|
||||||
|
implementation project(':infrastructure:inference:llama_cpp')
|
||||||
|
implementation project(':infrastructure:inference:commons')
|
||||||
|
|
||||||
implementation "com.github.ajalt.clikt:clikt:5.0.1"
|
implementation "com.github.ajalt.clikt:clikt:5.0.1"
|
||||||
|
|
||||||
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
|
implementation "io.ktor:ktor-client-logging:$ktor_version"
|
||||||
implementation "io.ktor:ktor-server-core:$ktor_version"
|
implementation "io.ktor:ktor-server-core:$ktor_version"
|
||||||
implementation "io.ktor:ktor-server-netty:$ktor_version"
|
implementation "io.ktor:ktor-server-netty:$ktor_version"
|
||||||
implementation "io.ktor:ktor-server-websockets:$ktor_version"
|
implementation "io.ktor:ktor-server-websockets:$ktor_version"
|
||||||
|
|||||||
@@ -1,5 +1,101 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
|
import com.correx.apps.server.registry.FileSystemWorkflowRegistry
|
||||||
|
import com.correx.apps.server.registry.ProviderRegistry
|
||||||
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||||
|
import com.correx.core.context.compression.DefaultContextCompressor
|
||||||
|
import com.correx.core.inference.DefaultInferenceRouter
|
||||||
|
import com.correx.core.inference.InferenceProjector
|
||||||
|
import com.correx.core.inference.InferenceRepository
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
|
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||||
|
import com.correx.core.risk.DefaultRiskAssessor
|
||||||
|
import com.correx.core.sessions.DefaultSessionReducer
|
||||||
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
|
import com.correx.core.sessions.SessionProjector
|
||||||
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import com.correx.core.transitions.evaluation.PromptResolver
|
||||||
|
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||||
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
|
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||||
|
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.netty.Netty
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
println("correx :: apps/server")
|
val eventStore = InfrastructureModule.createEventStore()
|
||||||
|
|
||||||
|
val llamaProvider = InfrastructureModule.createLlamaCppProvider(
|
||||||
|
modelId = System.getenv("CORREX_MODEL_ID") ?: "default",
|
||||||
|
modelPath = System.getenv("CORREX_MODEL_PATH") ?: "",
|
||||||
|
baseUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000",
|
||||||
|
)
|
||||||
|
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(llamaProvider))
|
||||||
|
|
||||||
|
val artifactRepository = InfrastructureModule.createArtifactRepository(eventStore)
|
||||||
|
|
||||||
|
val sessionRepository = DefaultSessionRepository(
|
||||||
|
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer()))
|
||||||
|
)
|
||||||
|
val inferenceRepository = InferenceRepository(
|
||||||
|
DefaultEventReplayer(eventStore, InferenceProjector())
|
||||||
|
)
|
||||||
|
val orchestrationRepository = OrchestrationRepository(
|
||||||
|
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer()))
|
||||||
|
)
|
||||||
|
|
||||||
|
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
|
||||||
|
|
||||||
|
val promptLoader = InfrastructureModule.createPromptLoader()
|
||||||
|
val promptResolver = PromptResolver { path -> promptLoader.load(path) }
|
||||||
|
|
||||||
|
val repositories = OrchestratorRepositories(
|
||||||
|
eventStore = eventStore,
|
||||||
|
inferenceRepository = inferenceRepository,
|
||||||
|
orchestrationRepository = orchestrationRepository,
|
||||||
|
sessionRepository = sessionRepository,
|
||||||
|
artifactRepository = artifactRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
val engines = OrchestratorEngines(
|
||||||
|
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
|
||||||
|
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
|
||||||
|
inferenceRouter = inferenceRouter,
|
||||||
|
validationPipeline = ValidationPipeline(validators = emptyList()),
|
||||||
|
approvalEngine = DefaultApprovalEngine(),
|
||||||
|
riskAssessor = DefaultRiskAssessor(),
|
||||||
|
promptResolver = promptResolver,
|
||||||
|
)
|
||||||
|
|
||||||
|
val orchestrator = DefaultSessionOrchestrator(
|
||||||
|
repositories = repositories,
|
||||||
|
engines = engines,
|
||||||
|
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||||
|
)
|
||||||
|
|
||||||
|
val workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader())
|
||||||
|
|
||||||
|
val module = ServerModule(
|
||||||
|
orchestrator = orchestrator,
|
||||||
|
eventStore = eventStore,
|
||||||
|
sessionRepository = sessionRepository,
|
||||||
|
workflowRegistry = workflowRegistry,
|
||||||
|
providerRegistry = infraRegistry.asServerRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
embeddedServer(Netty, port = 8080) {
|
||||||
|
configureServer(module)
|
||||||
|
}.start(wait = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DefaultProviderRegistry.asServerRegistry(): ProviderRegistry = object : ProviderRegistry {
|
||||||
|
override fun listAll() = this@asServerRegistry.listAll()
|
||||||
|
override suspend fun healthCheckAll() = this@asServerRegistry.healthCheckAll()
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.correx.apps.server.registry
|
||||||
|
|
||||||
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import com.correx.infrastructure.workflow.WorkflowLoader
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.listDirectoryEntries
|
||||||
|
|
||||||
|
class FileSystemWorkflowRegistry(
|
||||||
|
private val loader: WorkflowLoader,
|
||||||
|
private val workflowsDir: Path = Path.of(
|
||||||
|
System.getProperty("user.home"), ".config", "correx", "workflows"
|
||||||
|
),
|
||||||
|
) : WorkflowRegistry {
|
||||||
|
|
||||||
|
private val cache: Map<String, WorkflowGraph> by lazy { loadAll() }
|
||||||
|
|
||||||
|
override fun listAll(): List<WorkflowSummary> =
|
||||||
|
cache.values.map { WorkflowSummary(workflowId = it.id, description = it.id) }
|
||||||
|
|
||||||
|
override fun find(workflowId: String): WorkflowGraph? = cache[workflowId]
|
||||||
|
|
||||||
|
private fun loadAll(): Map<String, WorkflowGraph> {
|
||||||
|
if (!workflowsDir.exists()) return emptyMap()
|
||||||
|
return workflowsDir.listDirectoryEntries("*.toml")
|
||||||
|
.mapNotNull { file ->
|
||||||
|
runCatching { loader.load(file) }
|
||||||
|
.onFailure { logWarning(file, it) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
.associateBy { graph ->
|
||||||
|
graph.id.ifBlank { graph.let { it } .let { file -> file.start.value } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logWarning(file: Path, ex: Throwable) {
|
||||||
|
System.err.println("[WorkflowRegistry] Skipping $file: ${ex.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,16 @@ package com.correx.apps.server.routes
|
|||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.protocol.SessionConfigDto
|
import com.correx.apps.server.protocol.SessionConfigDto
|
||||||
import com.correx.apps.server.ws.SessionStreamHandler
|
import com.correx.apps.server.ws.SessionStreamHandler
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.server.application.call
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.application.application
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
@@ -14,6 +20,8 @@ import io.ktor.server.routing.get
|
|||||||
import io.ktor.server.routing.post
|
import io.ktor.server.routing.post
|
||||||
import io.ktor.server.routing.route
|
import io.ktor.server.routing.route
|
||||||
import io.ktor.server.websocket.webSocket
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -34,67 +42,95 @@ data class StartSessionResponse(val sessionId: String)
|
|||||||
|
|
||||||
fun Route.sessionRoutes(module: ServerModule) {
|
fun Route.sessionRoutes(module: ServerModule) {
|
||||||
val streamHandler = SessionStreamHandler(module)
|
val streamHandler = SessionStreamHandler(module)
|
||||||
|
|
||||||
route("/sessions") {
|
route("/sessions") {
|
||||||
get {
|
get { call.respond(emptyList<SessionSummaryResponse>()) }
|
||||||
val sessions = emptyList<SessionSummaryResponse>()
|
startSessionRoute(module)
|
||||||
call.respond(sessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
val body = call.receive<StartSessionRequest>()
|
|
||||||
module.workflowRegistry.find(body.workflowId)
|
|
||||||
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
|
|
||||||
|
|
||||||
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
|
||||||
call.respond(HttpStatusCode.Created, StartSessionResponse(sessionId.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
route("/{id}") {
|
route("/{id}") {
|
||||||
get {
|
getSessionRoute(module)
|
||||||
val id = call.parameters["id"]
|
cancelSessionRoute(module)
|
||||||
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
getEventsRoute(module)
|
||||||
val sessionId: SessionId = TypeId(id)
|
|
||||||
val session = runCatching { module.sessionRepository.getSession(sessionId) }.getOrNull()
|
|
||||||
?: return@get call.respond(HttpStatusCode.NotFound, "Session not found")
|
|
||||||
val response = SessionStateResponse(
|
|
||||||
sessionId = session.sessionId.value,
|
|
||||||
status = session.state.status.name,
|
|
||||||
createdAt = session.state.createdAt?.toString(),
|
|
||||||
)
|
|
||||||
call.respond(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
post("/cancel") {
|
|
||||||
val id = call.parameters["id"]
|
|
||||||
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
|
||||||
val sessionId: SessionId = TypeId(id)
|
|
||||||
module.orchestrator.cancel(sessionId)
|
|
||||||
call.respond(HttpStatusCode.OK)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/events") {
|
|
||||||
val id = call.parameters["id"]
|
|
||||||
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
|
||||||
val from = call.request.queryParameters["from"]?.toLongOrNull() ?: 0L
|
|
||||||
val sessionId: SessionId = TypeId(id)
|
|
||||||
val events = module.eventStore.readFrom(sessionId, from)
|
|
||||||
val response = events.map { e ->
|
|
||||||
EventResponse(
|
|
||||||
eventId = e.metadata.eventId.value,
|
|
||||||
sequence = e.sequence,
|
|
||||||
sessionId = e.metadata.sessionId.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
call.respond(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
webSocket("/stream") {
|
webSocket("/stream") {
|
||||||
val id = call.parameters["id"] ?: return@webSocket
|
val id = call.parameters["id"] ?: return@webSocket
|
||||||
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
|
||||||
val sessionId: SessionId = TypeId(id)
|
streamHandler.handle(this, TypeId(id), lastEventId)
|
||||||
streamHandler.handle(this, sessionId, lastEventId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Route.startSessionRoute(module: ServerModule) {
|
||||||
|
post {
|
||||||
|
val body = call.receive<StartSessionRequest>()
|
||||||
|
val graph = module.workflowRegistry.find(body.workflowId)
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
|
||||||
|
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
||||||
|
call.application.launch {
|
||||||
|
runCatching { module.orchestrator.run(sessionId, graph, OrchestrationConfig()) }
|
||||||
|
.onFailure { ex ->
|
||||||
|
System.err.println("[SessionRoutes] run failed for $sessionId: ${ex.message}")
|
||||||
|
module.eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = graph.start,
|
||||||
|
reason = ex.message ?: "unexpected orchestrator failure",
|
||||||
|
retryExhausted = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.respond(HttpStatusCode.Accepted, StartSessionResponse(sessionId.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Route.getSessionRoute(module: ServerModule) {
|
||||||
|
get {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val session = runCatching { module.sessionRepository.getSession(TypeId(id)) }.getOrNull()
|
||||||
|
?: return@get call.respond(HttpStatusCode.NotFound, "Session not found")
|
||||||
|
call.respond(
|
||||||
|
SessionStateResponse(
|
||||||
|
sessionId = session.sessionId.value,
|
||||||
|
status = session.state.status.name,
|
||||||
|
createdAt = session.state.createdAt?.toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Route.cancelSessionRoute(module: ServerModule) {
|
||||||
|
post("/cancel") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
module.orchestrator.cancel(TypeId(id))
|
||||||
|
call.respond(HttpStatusCode.OK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Route.getEventsRoute(module: ServerModule) {
|
||||||
|
get("/events") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val from = call.request.queryParameters["from"]?.toLongOrNull() ?: 0L
|
||||||
|
val events = module.eventStore.readFrom(TypeId(id), from)
|
||||||
|
call.respond(
|
||||||
|
events.map { e ->
|
||||||
|
EventResponse(
|
||||||
|
eventId = e.metadata.eventId.value,
|
||||||
|
sequence = e.sequence,
|
||||||
|
sessionId = e.metadata.sessionId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ package com.correx.apps.server.ws
|
|||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
|
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.inference.ProviderHealth
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
|
import com.correx.core.utils.TypeId
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
@@ -11,12 +20,15 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
private const val HEARTBEAT_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
class GlobalStreamHandler(private val module: ServerModule) {
|
class GlobalStreamHandler(private val module: ServerModule) {
|
||||||
|
|
||||||
suspend fun handle(session: DefaultWebSocketServerSession) {
|
suspend fun handle(session: DefaultWebSocketServerSession) {
|
||||||
|
System.err.println("[GlobalStream] client connected")
|
||||||
sendInitialSnapshot(session)
|
sendInitialSnapshot(session)
|
||||||
|
|
||||||
val heartbeatJob = session.launch {
|
val heartbeatJob = session.launch {
|
||||||
@@ -32,26 +44,39 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
val text = frame.readText()
|
||||||
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
||||||
.onSuccess { msg -> handleClientMessage(session, msg) }
|
.onSuccess { msg ->
|
||||||
|
System.err.println("[GlobalStream] recv: ${msg::class.simpleName}")
|
||||||
|
handleClientMessage(session, msg)
|
||||||
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
|
System.err.println("[GlobalStream] decode error: ${it.message}")
|
||||||
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
|
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
// client disconnected
|
System.err.println("[GlobalStream] client disconnected")
|
||||||
} finally {
|
} finally {
|
||||||
heartbeatJob.cancel()
|
heartbeatJob.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendInitialSnapshot(session: DefaultWebSocketServerSession) {
|
private suspend fun sendInitialSnapshot(session: DefaultWebSocketServerSession) {
|
||||||
val providerHealth = runCatching { module.providerRegistry.healthCheckAll() }.getOrDefault(emptyMap())
|
val providerHealth = runCatching { module.providerRegistry.healthCheckAll() }
|
||||||
providerHealth.forEach { (providerId, _) ->
|
.onFailure { System.err.println("[GlobalStream] healthCheckAll failed: ${it.message}") }
|
||||||
|
.getOrDefault(emptyMap())
|
||||||
|
System.err.println("[GlobalStream] snapshot: ${providerHealth.size} provider(s)")
|
||||||
|
providerHealth.forEach { (providerId, health) ->
|
||||||
|
val status = when (health) {
|
||||||
|
is ProviderHealth.Healthy -> "healthy"
|
||||||
|
is ProviderHealth.Degraded -> "degraded"
|
||||||
|
is ProviderHealth.Unavailable -> "unavailable"
|
||||||
|
}
|
||||||
|
System.err.println("[GlobalStream] provider=${providerId.value} status=$status")
|
||||||
val msg = ServerMessage.ProviderStatusChanged(
|
val msg = ServerMessage.ProviderStatusChanged(
|
||||||
providerId = providerId.value,
|
providerId = providerId.value,
|
||||||
status = com.correx.apps.server.protocol.ProviderHealthDto(providerId.value, "unknown", null),
|
status = ProviderHealthDto(providerId.value, status, null),
|
||||||
)
|
)
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
|
||||||
}
|
}
|
||||||
@@ -60,8 +85,55 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
|
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is ClientMessage.Ping -> Unit
|
is ClientMessage.Ping -> Unit
|
||||||
else -> {
|
|
||||||
val error = ServerMessage.ProtocolError("Unexpected message type in global stream")
|
is ClientMessage.StartSession -> {
|
||||||
|
val graph = module.workflowRegistry.find(msg.workflowId)
|
||||||
|
if (graph == null) {
|
||||||
|
val error = ServerMessage.ProtocolError("Unknown workflowId: ${msg.workflowId}")
|
||||||
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
|
||||||
|
System.err.println("[GlobalStream] starting session=${sessionId.value} workflow=${msg.workflowId}")
|
||||||
|
session.launch {
|
||||||
|
runCatching { module.orchestrator.run(sessionId, graph, OrchestrationConfig()) }
|
||||||
|
.onFailure { ex ->
|
||||||
|
System.err.println("[GlobalStreamHandler] run failed for $sessionId: ${ex.message}")
|
||||||
|
module.eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowFailedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = graph.start,
|
||||||
|
reason = ex.message ?: "unexpected orchestrator failure",
|
||||||
|
retryExhausted = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val started = ServerMessage.SessionStarted(sessionId = sessionId, workflowId = msg.workflowId)
|
||||||
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientMessage.CancelSession -> {
|
||||||
|
module.orchestrator.cancel(msg.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientMessage.ResumeSession -> {
|
||||||
|
val error = ServerMessage.ProtocolError("ResumeSession not supported")
|
||||||
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientMessage.ApprovalResponse -> {
|
||||||
|
val error = ServerMessage.ProtocolError("ApprovalResponse not supported on global stream")
|
||||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.correx.core.artifacts.repository
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.ArtifactState
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
|
||||||
|
interface ArtifactRepository {
|
||||||
|
fun getBySession(sessionId: SessionId): Map<ArtifactId, ArtifactState>
|
||||||
|
fun getByStage(sessionId: SessionId, stageId: StageId): Map<ArtifactId, ArtifactState>
|
||||||
|
fun getById(sessionId: SessionId, artifactId: ArtifactId): ArtifactState?
|
||||||
|
fun getByIds(sessionId: SessionId, artifactIds: Set<ArtifactId>): Map<ArtifactId, ArtifactState>
|
||||||
|
}
|
||||||
+2
@@ -4,6 +4,7 @@ import com.correx.core.approvals.domain.ApprovalEngine
|
|||||||
import com.correx.core.context.builder.ContextPackBuilder
|
import com.correx.core.context.builder.ContextPackBuilder
|
||||||
import com.correx.core.inference.InferenceRouter
|
import com.correx.core.inference.InferenceRouter
|
||||||
import com.correx.core.risk.RiskAssessor
|
import com.correx.core.risk.RiskAssessor
|
||||||
|
import com.correx.core.transitions.evaluation.PromptResolver
|
||||||
import com.correx.core.transitions.resolution.TransitionResolver
|
import com.correx.core.transitions.resolution.TransitionResolver
|
||||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
|
||||||
@@ -14,4 +15,5 @@ data class OrchestratorEngines(
|
|||||||
val validationPipeline: ValidationPipeline,
|
val validationPipeline: ValidationPipeline,
|
||||||
val approvalEngine: ApprovalEngine,
|
val approvalEngine: ApprovalEngine,
|
||||||
val riskAssessor: RiskAssessor,
|
val riskAssessor: RiskAssessor,
|
||||||
|
val promptResolver: PromptResolver = PromptResolver { "" },
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.core.kernel.orchestration
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
import com.correx.core.inference.InferenceRepository
|
import com.correx.core.inference.InferenceRepository
|
||||||
import com.correx.core.sessions.DefaultSessionRepository
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
@@ -9,4 +10,5 @@ data class OrchestratorRepositories(
|
|||||||
val inferenceRepository: InferenceRepository,
|
val inferenceRepository: InferenceRepository,
|
||||||
val orchestrationRepository: OrchestrationRepository,
|
val orchestrationRepository: OrchestrationRepository,
|
||||||
val sessionRepository: DefaultSessionRepository,
|
val sessionRepository: DefaultSessionRepository,
|
||||||
|
val artifactRepository: ArtifactRepository,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ plugins {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":core:events"))
|
implementation(project(":core:events"))
|
||||||
implementation(project(":core:inference"))
|
implementation(project(":core:inference"))
|
||||||
|
implementation(project(":core:artifacts"))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.correx.core.transitions.conditions
|
||||||
|
|
||||||
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
|
object AlwaysTrue : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) = true
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.correx.core.transitions.conditions
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||||
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
|
data class ArtifactPresent(val artifactId: ArtifactId) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
context.artifacts[artifactId] != null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ArtifactAbsent(val artifactId: ArtifactId) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
context.artifacts[artifactId] == null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ArtifactValidated(val artifactId: ArtifactId) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
context.artifacts[artifactId]?.phase == ArtifactLifecyclePhase.VALIDATED
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.correx.core.transitions.conditions
|
||||||
|
|
||||||
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
|
data class AllOf(val conditions: List<TransitionCondition>) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
conditions.all { it.evaluate(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AnyOf(val conditions: List<TransitionCondition>) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
conditions.any { it.evaluate(context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Not(val condition: TransitionCondition) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
!condition.evaluate(context)
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.correx.core.transitions.conditions
|
||||||
|
|
||||||
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
|
data class VariableEquals(val key: String, val value: String) : TransitionCondition {
|
||||||
|
override fun evaluate(context: EvaluationContext) =
|
||||||
|
context.variables[key] == value
|
||||||
|
}
|
||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
package com.correx.core.transitions.evaluation
|
package com.correx.core.transitions.evaluation
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.ArtifactState
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
|
||||||
data class EvaluationContext(
|
data class EvaluationContext(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val currentStage: StageId,
|
val currentStage: StageId,
|
||||||
val variables: Map<String, String>
|
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||||
|
val variables: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.correx.core.transitions.evaluation
|
||||||
|
|
||||||
|
fun interface PromptResolver {
|
||||||
|
fun resolve(path: String): String
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@ package com.correx.core.transitions.evaluation
|
|||||||
|
|
||||||
import com.correx.core.transitions.graph.TransitionCondition
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
interface TransitionConditionEvaluator {
|
fun interface TransitionConditionEvaluator {
|
||||||
fun evaluate(
|
fun evaluate(
|
||||||
condition: TransitionCondition,
|
condition: TransitionCondition,
|
||||||
context: EvaluationContext
|
context: EvaluationContext
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.core.transitions.graph
|
package com.correx.core.transitions.graph
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.inference.GenerationConfig
|
import com.correx.core.inference.GenerationConfig
|
||||||
import com.correx.core.inference.ModelCapability
|
import com.correx.core.inference.ModelCapability
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -15,4 +16,7 @@ data class StageConfig(
|
|||||||
),
|
),
|
||||||
val allowedTools: Set<String> = emptySet(),
|
val allowedTools: Set<String> = emptySet(),
|
||||||
val maxRetries: Int = 3,
|
val maxRetries: Int = 3,
|
||||||
|
val produces: Set<ArtifactId> = emptySet(),
|
||||||
|
val needs: Set<ArtifactId> = emptySet(),
|
||||||
|
val metadata: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.correx.core.transitions.graph
|
|||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
|
||||||
data class WorkflowGraph(
|
data class WorkflowGraph(
|
||||||
|
val id: String = "",
|
||||||
val stages: Map<StageId, StageConfig>,
|
val stages: Map<StageId, StageConfig>,
|
||||||
val transitions: Set<TransitionEdge>,
|
val transitions: Set<TransitionEdge>,
|
||||||
val start: StageId,
|
val start: StageId,
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
package com.correx.core.transitions.conditions
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.ArtifactState
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.ArtifactLifecyclePhase
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TransitionConditionTest {
|
||||||
|
|
||||||
|
private val baseContext = EvaluationContext(
|
||||||
|
sessionId = SessionId("test-session"),
|
||||||
|
currentStage = StageId("stage-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun contextWith(
|
||||||
|
artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||||
|
variables: Map<String, String> = emptyMap(),
|
||||||
|
) = baseContext.copy(artifacts = artifacts, variables = variables)
|
||||||
|
|
||||||
|
// AlwaysTrue
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AlwaysTrue returns true`() {
|
||||||
|
assertTrue(AlwaysTrue.evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactPresent
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactPresent returns true when artifact in map`() {
|
||||||
|
val id = ArtifactId("a1")
|
||||||
|
val ctx = contextWith(artifacts = mapOf(id to ArtifactState()))
|
||||||
|
assertTrue(ArtifactPresent(id).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactPresent returns false when artifact absent`() {
|
||||||
|
val ctx = contextWith()
|
||||||
|
assertFalse(ArtifactPresent(ArtifactId("a1")).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactAbsent
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactAbsent returns false when artifact in map`() {
|
||||||
|
val id = ArtifactId("a1")
|
||||||
|
val ctx = contextWith(artifacts = mapOf(id to ArtifactState()))
|
||||||
|
assertFalse(ArtifactAbsent(id).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactAbsent returns true when artifact absent`() {
|
||||||
|
val ctx = contextWith()
|
||||||
|
assertTrue(ArtifactAbsent(ArtifactId("a1")).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactValidated
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactValidated returns true when phase is VALIDATED`() {
|
||||||
|
val id = ArtifactId("a1")
|
||||||
|
val ctx = contextWith(artifacts = mapOf(id to ArtifactState(phase = ArtifactLifecyclePhase.VALIDATED)))
|
||||||
|
assertTrue(ArtifactValidated(id).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactValidated returns false when phase is CREATED`() {
|
||||||
|
val id = ArtifactId("a1")
|
||||||
|
val ctx = contextWith(artifacts = mapOf(id to ArtifactState(phase = ArtifactLifecyclePhase.CREATED)))
|
||||||
|
assertFalse(ArtifactValidated(id).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactValidated returns false when phase is REJECTED`() {
|
||||||
|
val id = ArtifactId("a1")
|
||||||
|
val ctx = contextWith(artifacts = mapOf(id to ArtifactState(phase = ArtifactLifecyclePhase.REJECTED)))
|
||||||
|
assertFalse(ArtifactValidated(id).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ArtifactValidated returns false when artifact absent`() {
|
||||||
|
val ctx = contextWith()
|
||||||
|
assertFalse(ArtifactValidated(ArtifactId("a1")).evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VariableEquals
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `VariableEquals returns true when key matches value`() {
|
||||||
|
val ctx = contextWith(variables = mapOf("status" to "done"))
|
||||||
|
assertTrue(VariableEquals("status", "done").evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `VariableEquals returns false when key missing`() {
|
||||||
|
val ctx = contextWith()
|
||||||
|
assertFalse(VariableEquals("status", "done").evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `VariableEquals returns false when value differs`() {
|
||||||
|
val ctx = contextWith(variables = mapOf("status" to "pending"))
|
||||||
|
assertFalse(VariableEquals("status", "done").evaluate(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllOf
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AllOf with empty list returns true (vacuous truth)`() {
|
||||||
|
assertTrue(AllOf(emptyList()).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AllOf with all true conditions returns true`() {
|
||||||
|
assertTrue(AllOf(listOf(AlwaysTrue, AlwaysTrue)).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AllOf with one false condition returns false`() {
|
||||||
|
val alwaysFalse = Not(AlwaysTrue)
|
||||||
|
assertFalse(AllOf(listOf(AlwaysTrue, alwaysFalse)).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyOf
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AnyOf with empty list returns false (vacuous falsity)`() {
|
||||||
|
assertFalse(AnyOf(emptyList()).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AnyOf with one true condition returns true`() {
|
||||||
|
val alwaysFalse = Not(AlwaysTrue)
|
||||||
|
assertTrue(AnyOf(listOf(alwaysFalse, AlwaysTrue)).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `AnyOf with all false conditions returns false`() {
|
||||||
|
val alwaysFalse = Not(AlwaysTrue)
|
||||||
|
assertFalse(AnyOf(listOf(alwaysFalse, alwaysFalse)).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Not inverts true to false`() {
|
||||||
|
assertFalse(Not(AlwaysTrue).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Not inverts false to true`() {
|
||||||
|
assertTrue(Not(Not(AlwaysTrue)).evaluate(baseContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ dependencies {
|
|||||||
implementation project(":infrastructure:persistence")
|
implementation project(":infrastructure:persistence")
|
||||||
implementation project(":infrastructure:tools")
|
implementation project(":infrastructure:tools")
|
||||||
implementation project(":infrastructure:tools:filesystem")
|
implementation project(":infrastructure:tools:filesystem")
|
||||||
|
implementation project(":infrastructure:workflow")
|
||||||
|
implementation project(":core:artifacts")
|
||||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ dependencies {
|
|||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
||||||
implementation(project(":core:events"))
|
implementation(project(":core:events"))
|
||||||
implementation(project(":core:sessions"))
|
implementation(project(":core:sessions"))
|
||||||
|
implementation(project(":core:artifacts"))
|
||||||
implementation "org.xerial:sqlite-jdbc"
|
implementation "org.xerial:sqlite-jdbc"
|
||||||
testImplementation(testFixtures(project(":testing:contracts")))
|
testImplementation(testFixtures(project(":testing:contracts")))
|
||||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package com.correx.infrastructure.persistence.artifact
|
||||||
|
|
||||||
|
import com.correx.core.artifacts.ArtifactProjector
|
||||||
|
import com.correx.core.artifacts.ArtifactReducer
|
||||||
|
import com.correx.core.artifacts.ArtifactState
|
||||||
|
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||||
|
import com.correx.core.events.events.ArtifactArchivedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactRejectedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactRelationshipAddedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactSupersededEvent
|
||||||
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
|
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class LiveArtifactRepository(
|
||||||
|
private val eventStore: EventStore,
|
||||||
|
private val reducer: ArtifactReducer,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||||
|
) : ArtifactRepository {
|
||||||
|
|
||||||
|
private val artifactCache = ConcurrentHashMap<SessionId, ConcurrentHashMap<ArtifactId, ArtifactState>>()
|
||||||
|
private val stageIndex = ConcurrentHashMap<SessionId, ConcurrentHashMap<ArtifactId, StageId>>()
|
||||||
|
private val subscriptions = ConcurrentHashMap<SessionId, Job>()
|
||||||
|
private val projector = ArtifactProjector(reducer)
|
||||||
|
|
||||||
|
override fun getBySession(sessionId: SessionId): Map<ArtifactId, ArtifactState> {
|
||||||
|
ensureSubscribed(sessionId)
|
||||||
|
return artifactCache[sessionId]?.toMap() ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getByStage(sessionId: SessionId, stageId: StageId): Map<ArtifactId, ArtifactState> {
|
||||||
|
ensureSubscribed(sessionId)
|
||||||
|
val cache = artifactCache[sessionId] ?: emptyMap<ArtifactId, ArtifactState>()
|
||||||
|
val index = stageIndex[sessionId] ?: emptyMap<ArtifactId, StageId>()
|
||||||
|
return cache.filter { (artifactId, _) -> index[artifactId] == stageId }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getById(sessionId: SessionId, artifactId: ArtifactId): ArtifactState? {
|
||||||
|
ensureSubscribed(sessionId)
|
||||||
|
return artifactCache[sessionId]?.get(artifactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getByIds(sessionId: SessionId, artifactIds: Set<ArtifactId>): Map<ArtifactId, ArtifactState> {
|
||||||
|
ensureSubscribed(sessionId)
|
||||||
|
val cache = artifactCache[sessionId] ?: return emptyMap()
|
||||||
|
return artifactIds.mapNotNull { id -> cache[id]?.let { id to it } }.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureSubscribed(sessionId: SessionId) {
|
||||||
|
subscriptions.computeIfAbsent(sessionId) {
|
||||||
|
scope.launch {
|
||||||
|
eventStore.subscribe(sessionId).collect { event ->
|
||||||
|
processEvent(sessionId, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processEvent(sessionId: SessionId, event: StoredEvent) {
|
||||||
|
val artifactId = extractArtifactId(event) ?: return
|
||||||
|
val sessionArtifacts = artifactCache.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||||
|
val currentState = sessionArtifacts[artifactId] ?: projector.initial()
|
||||||
|
val newState = projector.apply(currentState, event)
|
||||||
|
sessionArtifacts[artifactId] = newState
|
||||||
|
|
||||||
|
val payload = event.payload
|
||||||
|
if (payload is ArtifactCreatedEvent) {
|
||||||
|
val sessionStages = stageIndex.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||||
|
sessionStages[artifactId] = payload.stageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractArtifactId(event: StoredEvent): ArtifactId? =
|
||||||
|
when (val p = event.payload) {
|
||||||
|
is ArtifactCreatedEvent -> p.artifactId
|
||||||
|
is ArtifactValidatingEvent -> p.artifactId
|
||||||
|
is ArtifactValidatedEvent -> p.artifactId
|
||||||
|
is ArtifactRejectedEvent -> p.artifactId
|
||||||
|
is ArtifactSupersededEvent -> p.artifactId
|
||||||
|
is ArtifactArchivedEvent -> p.artifactId
|
||||||
|
is ArtifactRelationshipAddedEvent -> p.sourceId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,21 +12,37 @@ import com.correx.core.tools.contract.ToolExecutor
|
|||||||
import com.correx.core.tools.registry.ToolRegistry
|
import com.correx.core.tools.registry.ToolRegistry
|
||||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||||
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
||||||
|
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||||
import com.correx.infrastructure.inference.commons.ModelManager
|
import com.correx.infrastructure.inference.commons.ModelManager
|
||||||
|
import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||||
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
||||||
|
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||||
import com.correx.infrastructure.persistence.SqliteEventStore
|
import com.correx.infrastructure.persistence.SqliteEventStore
|
||||||
import com.correx.infrastructure.tools.DefaultToolRegistry
|
import com.correx.infrastructure.tools.DefaultToolRegistry
|
||||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||||
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
||||||
import com.correx.infrastructure.tools.ToolConfig
|
import com.correx.infrastructure.tools.ToolConfig
|
||||||
import com.correx.infrastructure.tools.buildTools
|
import com.correx.infrastructure.tools.buildTools
|
||||||
|
import com.correx.infrastructure.workflow.FileSystemPromptLoader
|
||||||
|
import com.correx.infrastructure.workflow.PromptLoader
|
||||||
|
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
||||||
|
import com.correx.infrastructure.workflow.WorkflowLoader
|
||||||
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||||
|
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||||
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.sql.DriverManager
|
import java.sql.DriverManager
|
||||||
|
|
||||||
object InfrastructureModule {
|
object InfrastructureModule {
|
||||||
fun createEventStore(dbPath: String): EventStore =
|
private val defaultDbPath: String =
|
||||||
SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
"${System.getProperty("user.home")}/.config/correx/correx.db"
|
||||||
|
|
||||||
|
fun createEventStore(dbPath: String = defaultDbPath): EventStore {
|
||||||
|
val dir = java.io.File(dbPath).parentFile
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
return SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
||||||
|
}
|
||||||
|
|
||||||
fun createModelManager(
|
fun createModelManager(
|
||||||
eventStore: EventStore,
|
eventStore: EventStore,
|
||||||
@@ -47,6 +63,31 @@ object InfrastructureModule {
|
|||||||
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
|
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
|
||||||
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
|
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
|
||||||
|
|
||||||
|
fun createLlamaCppProvider(
|
||||||
|
modelId: String,
|
||||||
|
modelPath: String,
|
||||||
|
baseUrl: String = "http://127.0.0.1:10000",
|
||||||
|
residencyMode: ResidencyMode = ResidencyMode.PERSISTENT,
|
||||||
|
): LlamaCppInferenceProvider = LlamaCppInferenceProvider(
|
||||||
|
descriptor = ModelDescriptor(
|
||||||
|
modelId = modelId,
|
||||||
|
modelPath = modelPath,
|
||||||
|
residencyMode = residencyMode,
|
||||||
|
),
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createProviderRegistry(
|
||||||
|
providers: List<InferenceProvider> = emptyList(),
|
||||||
|
): DefaultProviderRegistry = DefaultProviderRegistry(providers)
|
||||||
|
|
||||||
|
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
|
||||||
|
LiveArtifactRepository(eventStore, DefaultArtifactReducer())
|
||||||
|
|
||||||
|
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
|
||||||
|
|
||||||
|
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
|
||||||
|
|
||||||
fun createToolExecutor(
|
fun createToolExecutor(
|
||||||
registry: ToolRegistry,
|
registry: ToolRegistry,
|
||||||
approvalEngine: ApprovalEngine,
|
approvalEngine: ApprovalEngine,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:transitions"))
|
||||||
|
implementation(project(":core:inference"))
|
||||||
|
implementation(project(":core:events"))
|
||||||
|
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
|
||||||
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
|
testImplementation "org.jetbrains.kotlin:kotlin-test-junit5"
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.transitions.conditions.AllOf
|
||||||
|
import com.correx.core.transitions.conditions.AlwaysTrue
|
||||||
|
import com.correx.core.transitions.conditions.AnyOf
|
||||||
|
import com.correx.core.transitions.conditions.ArtifactAbsent
|
||||||
|
import com.correx.core.transitions.conditions.ArtifactPresent
|
||||||
|
import com.correx.core.transitions.conditions.ArtifactValidated
|
||||||
|
import com.correx.core.transitions.conditions.Not
|
||||||
|
import com.correx.core.transitions.conditions.VariableEquals
|
||||||
|
import com.correx.core.transitions.graph.TransitionCondition
|
||||||
|
|
||||||
|
fun ConditionSpec.toCondition(): TransitionCondition = when (type) {
|
||||||
|
"always_true" -> AlwaysTrue
|
||||||
|
"artifact_present" -> buildArtifactPresent()
|
||||||
|
"artifact_absent" -> buildArtifactAbsent()
|
||||||
|
"artifact_validated" -> buildArtifactValidated()
|
||||||
|
"variable_equals" -> buildVariableEquals()
|
||||||
|
"all_of" -> AllOf(conditions.map { it.toCondition() })
|
||||||
|
"any_of" -> AnyOf(conditions.map { it.toCondition() })
|
||||||
|
"not" -> Not((condition ?: error("condition required for not")).toCondition())
|
||||||
|
else -> throw WorkflowValidationException("unknown condition type: $type")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConditionSpec.buildArtifactPresent() =
|
||||||
|
ArtifactPresent(ArtifactId(artifactId ?: error("artifact_id required for artifact_present")))
|
||||||
|
|
||||||
|
private fun ConditionSpec.buildArtifactAbsent() =
|
||||||
|
ArtifactAbsent(ArtifactId(artifactId ?: error("artifact_id required for artifact_absent")))
|
||||||
|
|
||||||
|
private fun ConditionSpec.buildArtifactValidated() =
|
||||||
|
ArtifactValidated(ArtifactId(artifactId ?: error("artifact_id required for artifact_validated")))
|
||||||
|
|
||||||
|
private fun ConditionSpec.buildVariableEquals() =
|
||||||
|
VariableEquals(
|
||||||
|
key ?: error("key required for variable_equals"),
|
||||||
|
value ?: error("value required for variable_equals"),
|
||||||
|
)
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
data class ConditionSpec(
|
||||||
|
val type: String,
|
||||||
|
val artifactId: String? = null,
|
||||||
|
val key: String? = null,
|
||||||
|
val value: String? = null,
|
||||||
|
val conditions: List<ConditionSpec> = emptyList(),
|
||||||
|
val condition: ConditionSpec? = null,
|
||||||
|
)
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
|
||||||
|
class FileSystemPromptLoader(
|
||||||
|
private val configDir: Path = Path.of(System.getProperty("user.home"), ".config", "correx", "prompts"),
|
||||||
|
) : PromptLoader {
|
||||||
|
override fun load(path: String): String {
|
||||||
|
val local = Path.of(path)
|
||||||
|
if (local.exists()) return local.readText()
|
||||||
|
|
||||||
|
val fromConfig = configDir.resolve(path)
|
||||||
|
if (fromConfig.exists()) return fromConfig.readText()
|
||||||
|
|
||||||
|
throw PromptNotFoundException("Prompt not found: $path (searched: $local, $fromConfig)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
interface PromptLoader {
|
||||||
|
fun load(path: String): String
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
class PromptNotFoundException(message: String) : Exception(message)
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.TransitionId
|
||||||
|
import com.correx.core.transitions.graph.StageConfig
|
||||||
|
import com.correx.core.transitions.graph.TransitionEdge
|
||||||
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
|
import com.fasterxml.jackson.dataformat.toml.TomlMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
|
||||||
|
private data class WorkflowFile(
|
||||||
|
val id: String = "",
|
||||||
|
val start: String = "",
|
||||||
|
val stages: List<StageSection> = emptyList(),
|
||||||
|
val transitions: List<TransitionSection> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class StageSection(
|
||||||
|
val id: String = "",
|
||||||
|
val prompt: String? = null,
|
||||||
|
val produces: List<String> = emptyList(),
|
||||||
|
val needs: List<String> = emptyList(),
|
||||||
|
@JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
|
||||||
|
@JsonProperty("token_budget") val tokenBudget: Int = 4096,
|
||||||
|
@JsonProperty("max_retries") val maxRetries: Int = 3,
|
||||||
|
)
|
||||||
|
|
||||||
|
// condition fields flattened into the transition row
|
||||||
|
private data class TransitionSection(
|
||||||
|
val id: String = "",
|
||||||
|
val from: String = "",
|
||||||
|
val to: String = "",
|
||||||
|
@JsonProperty("condition_type") val conditionType: String = "",
|
||||||
|
@JsonProperty("condition_artifact_id") val conditionArtifactId: String? = null,
|
||||||
|
@JsonProperty("condition_key") val conditionKey: String? = null,
|
||||||
|
@JsonProperty("condition_value") val conditionValue: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val TERMINAL = "done"
|
||||||
|
|
||||||
|
private val mapper = TomlMapper.builder()
|
||||||
|
.addModule(kotlinModule())
|
||||||
|
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
class TomlWorkflowLoader : WorkflowLoader {
|
||||||
|
override fun load(path: Path): WorkflowGraph {
|
||||||
|
val raw = path.readText()
|
||||||
|
val file = runCatching { mapper.readValue<WorkflowFile>(raw) }
|
||||||
|
.getOrElse { throw WorkflowValidationException("Failed to parse workflow TOML: ${it.message}") }
|
||||||
|
return file.toWorkflowGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WorkflowFile.toWorkflowGraph(): WorkflowGraph {
|
||||||
|
val startId = StageId(start)
|
||||||
|
val stageMap = stages.associate { s ->
|
||||||
|
StageId(s.id) to StageConfig(
|
||||||
|
produces = s.produces.map { ArtifactId(it) }.toSet(),
|
||||||
|
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||||
|
allowedTools = s.allowedTools.toSet(),
|
||||||
|
tokenBudget = s.tokenBudget,
|
||||||
|
maxRetries = s.maxRetries,
|
||||||
|
metadata = buildMap { s.prompt?.let { put("prompt", it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val declaredIds = stageMap.keys.map { it.value }.toSet()
|
||||||
|
validate(start, declaredIds, transitions)
|
||||||
|
|
||||||
|
val allProduced = stageMap.values.flatMap { it.produces }.toSet()
|
||||||
|
stageMap.forEach { (stageId, config) ->
|
||||||
|
config.needs.forEach { needed ->
|
||||||
|
if (needed !in allProduced) {
|
||||||
|
throw WorkflowValidationException(
|
||||||
|
"Stage '${stageId.value}' needs artifact '${needed.value}' but no stage produces it"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val transitionSet = transitions.map { t ->
|
||||||
|
TransitionEdge(
|
||||||
|
id = TransitionId(t.id),
|
||||||
|
from = StageId(t.from),
|
||||||
|
to = StageId(t.to),
|
||||||
|
condition = ConditionSpec(
|
||||||
|
type = t.conditionType,
|
||||||
|
artifactId = t.conditionArtifactId,
|
||||||
|
key = t.conditionKey,
|
||||||
|
value = t.conditionValue,
|
||||||
|
).toCondition(),
|
||||||
|
)
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
|
return WorkflowGraph(id = id, stages = stageMap, transitions = transitionSet, start = startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ThrowsCount")
|
||||||
|
private fun validate(
|
||||||
|
start: String,
|
||||||
|
declaredStages: Set<String>,
|
||||||
|
transitions: List<TransitionSection>,
|
||||||
|
) {
|
||||||
|
if (start !in declaredStages) {
|
||||||
|
throw WorkflowValidationException("start stage '$start' is not declared in stages")
|
||||||
|
}
|
||||||
|
val seenIds = mutableSetOf<String>()
|
||||||
|
transitions.forEach { t ->
|
||||||
|
if (!seenIds.add(t.id)) {
|
||||||
|
throw WorkflowValidationException("duplicate transition id: '${t.id}'")
|
||||||
|
}
|
||||||
|
checkTransitionStage(t, "from", t.from, declaredStages)
|
||||||
|
checkTransitionStage(t, "to", t.to, declaredStages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkTransitionStage(t: TransitionSection, field: String, stageRef: String, declared: Set<String>) {
|
||||||
|
if (stageRef !in declared && (field != "to" || stageRef != TERMINAL)) {
|
||||||
|
throw WorkflowValidationException(
|
||||||
|
"transition '${t.id}' references unknown $field-stage '$stageRef'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
interface WorkflowLoader {
|
||||||
|
fun load(path: Path): WorkflowGraph
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
class WorkflowValidationException(message: String) : Exception(message)
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertContains
|
||||||
|
|
||||||
|
class FileSystemPromptLoaderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads from absolute path when file exists`() {
|
||||||
|
val file = Files.createTempFile("prompt", ".md").also { it.writeText("hello world") }
|
||||||
|
val loader = FileSystemPromptLoader()
|
||||||
|
assertEquals("hello world", loader.load(file.toAbsolutePath().toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to configDir when local path not found`() {
|
||||||
|
val configDir = Files.createTempDirectory("correx-prompts")
|
||||||
|
configDir.resolve("test.md").also { it.writeText("from config") }
|
||||||
|
val loader = FileSystemPromptLoader(configDir = configDir)
|
||||||
|
assertEquals("from config", loader.load("test.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throws PromptNotFoundException with both paths when not found`() {
|
||||||
|
val configDir = Files.createTempDirectory("correx-prompts-empty")
|
||||||
|
val loader = FileSystemPromptLoader(configDir = configDir)
|
||||||
|
val ex = assertThrows<PromptNotFoundException> { loader.load("missing.md") }
|
||||||
|
assertContains(ex.message!!, "missing.md")
|
||||||
|
assertContains(ex.message!!, configDir.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `local path takes precedence over configDir`() {
|
||||||
|
val localFile = Files.createTempFile("prompt", ".md").also { it.writeText("local") }
|
||||||
|
val configDir = Files.createTempDirectory("correx-prompts")
|
||||||
|
configDir.resolve(localFile.fileName.toString()).also { it.writeText("from config") }
|
||||||
|
|
||||||
|
val loader = FileSystemPromptLoader(configDir = configDir)
|
||||||
|
assertEquals("local", loader.load(localFile.toAbsolutePath().toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class TomlWorkflowLoaderTest {
|
||||||
|
|
||||||
|
private val loader = TomlWorkflowLoader()
|
||||||
|
|
||||||
|
private val validToml = """
|
||||||
|
id = "healthcheck"
|
||||||
|
start = "collect"
|
||||||
|
|
||||||
|
[[stages]]
|
||||||
|
id = "collect"
|
||||||
|
prompt = "prompts/collect.md"
|
||||||
|
produces = ["system_stats"]
|
||||||
|
allowed_tools = ["ShellTool"]
|
||||||
|
token_budget = 2048
|
||||||
|
max_retries = 2
|
||||||
|
|
||||||
|
[[stages]]
|
||||||
|
id = "report"
|
||||||
|
prompt = "prompts/report.md"
|
||||||
|
needs = ["system_stats"]
|
||||||
|
produces = ["report"]
|
||||||
|
token_budget = 2048
|
||||||
|
|
||||||
|
[[transitions]]
|
||||||
|
id = "collect-to-report"
|
||||||
|
from = "collect"
|
||||||
|
to = "report"
|
||||||
|
condition_type = "artifact_present"
|
||||||
|
condition_artifact_id = "system_stats"
|
||||||
|
|
||||||
|
[[transitions]]
|
||||||
|
id = "report-to-done"
|
||||||
|
from = "report"
|
||||||
|
to = "done"
|
||||||
|
condition_type = "always_true"
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid toml produces correct WorkflowGraph`() {
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
|
||||||
|
val graph = loader.load(path)
|
||||||
|
|
||||||
|
assertEquals("collect", graph.start.value)
|
||||||
|
assertEquals(2, graph.stages.size)
|
||||||
|
assertEquals(2, graph.transitions.size)
|
||||||
|
|
||||||
|
val collectStage = graph.stages[graph.start]!!
|
||||||
|
assertEquals(setOf("system_stats"), collectStage.produces.map { it.value }.toSet())
|
||||||
|
assertEquals("prompts/collect.md", collectStage.metadata["prompt"])
|
||||||
|
|
||||||
|
val reportStage = graph.stages.values.first { it.produces.any { a -> a.value == "report" } }
|
||||||
|
assertEquals(setOf("system_stats"), reportStage.needs.map { it.value }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing stage reference throws WorkflowValidationException`() {
|
||||||
|
val bad = validToml.replace("from = \"collect\"", "from = \"nonexistent\"")
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
|
||||||
|
assertThrows<WorkflowValidationException> { loader.load(path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate transition ids throw WorkflowValidationException`() {
|
||||||
|
val bad = validToml.replace("id = \"report-to-done\"", "id = \"collect-to-report\"")
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
|
||||||
|
assertThrows<WorkflowValidationException> { loader.load(path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown start stage throws WorkflowValidationException`() {
|
||||||
|
val bad = validToml.replace("start = \"collect\"", "start = \"missing\"")
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
|
||||||
|
assertThrows<WorkflowValidationException> { loader.load(path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unsatisfied needs throws WorkflowValidationException`() {
|
||||||
|
val bad = validToml.replace(
|
||||||
|
"needs = [\"system_stats\"]",
|
||||||
|
"needs = [\"nonexistent_artifact\"]",
|
||||||
|
)
|
||||||
|
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
|
||||||
|
assertThrows<WorkflowValidationException> { loader.load(path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# healthcheck_execute.md
|
||||||
|
|
||||||
|
Run `scripts/healthcheck.sh` and report the results.
|
||||||
|
If the script fails, report the error and exit code.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# healthcheck_write.md
|
||||||
|
|
||||||
|
Write a bash script to `scripts/healthcheck.sh` that reports:
|
||||||
|
- RAM usage %
|
||||||
|
- CPU usage %
|
||||||
|
- GPU usage % and VRAM usage % if an NVIDIA or AMD GPU is detected
|
||||||
|
|
||||||
|
Use standard tools only (free, top/vmstat, nvidia-smi, rocm-smi).
|
||||||
|
If no GPU is detected, skip that section gracefully.
|
||||||
|
Make the script executable.
|
||||||
@@ -40,6 +40,7 @@ include ':infrastructure:tools:filesystem'
|
|||||||
include ':infrastructure:security'
|
include ':infrastructure:security'
|
||||||
include ':infrastructure:scheduler'
|
include ':infrastructure:scheduler'
|
||||||
include ':infrastructure:telemetry'
|
include ':infrastructure:telemetry'
|
||||||
|
include ':infrastructure:workflow'
|
||||||
|
|
||||||
include ':interfaces:api'
|
include ':interfaces:api'
|
||||||
include ':interfaces:cli'
|
include ':interfaces:cli'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies {
|
|||||||
testImplementation(project(":core:kernel"))
|
testImplementation(project(":core:kernel"))
|
||||||
testImplementation(project(":core:risk"))
|
testImplementation(project(":core:risk"))
|
||||||
testImplementation(project(":infrastructure:persistence"))
|
testImplementation(project(":infrastructure:persistence"))
|
||||||
|
testImplementation(project(":core:artifacts"))
|
||||||
testImplementation(project(":testing:fixtures"))
|
testImplementation(project(":testing:fixtures"))
|
||||||
testImplementation(project(":testing:kernel"))
|
testImplementation(project(":testing:kernel"))
|
||||||
testImplementation(project(":testing:contracts"))
|
testImplementation(project(":testing:contracts"))
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ import com.correx.core.transitions.graph.StageConfig
|
|||||||
import com.correx.core.transitions.graph.WorkflowGraph
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
import com.correx.core.validation.approval.ApprovalTrigger
|
import com.correx.core.validation.approval.ApprovalTrigger
|
||||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
import com.correx.infrastructure.persistence.InMemoryEventStore
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||||
import com.correx.testing.fixtures.InferenceFixtures
|
import com.correx.testing.fixtures.InferenceFixtures
|
||||||
import com.correx.testing.fixtures.context.ContextFixtures
|
import com.correx.testing.fixtures.context.ContextFixtures
|
||||||
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
||||||
@@ -82,6 +84,7 @@ class SessionOrchestratorIntegrationTest {
|
|||||||
inferenceRepository = inferenceRepository,
|
inferenceRepository = inferenceRepository,
|
||||||
orchestrationRepository = orchestrationRepository,
|
orchestrationRepository = orchestrationRepository,
|
||||||
sessionRepository = sessionRepository,
|
sessionRepository = sessionRepository,
|
||||||
|
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val engines = OrchestratorEngines(
|
private val engines = OrchestratorEngines(
|
||||||
|
|||||||
Reference in New Issue
Block a user