epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:approvals"))
|
||||
implementation(project(":core:sessions"))
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.state.ToolInvocationRecord
|
||||
import com.correx.core.tools.state.ToolInvocationStatus
|
||||
import com.correx.core.tools.state.ToolState
|
||||
|
||||
class DefaultToolReducer : ToolReducer {
|
||||
override fun reduce(state: ToolState, event: StoredEvent): ToolState =
|
||||
when (val p = event.payload) {
|
||||
is ToolInvocationRequestedEvent -> state.copy(
|
||||
invocations = state.invocations + ToolInvocationRecord(
|
||||
invocationId = p.invocationId,
|
||||
toolName = p.toolName,
|
||||
tier = p.tier,
|
||||
request = p.request,
|
||||
status = ToolInvocationStatus.REQUESTED,
|
||||
requestedAt = event.metadata.timestamp
|
||||
)
|
||||
)
|
||||
is ToolExecutionStartedEvent -> state.updateRecord(p.invocationId) {
|
||||
it.copy(status = ToolInvocationStatus.STARTED)
|
||||
}
|
||||
is ToolExecutionCompletedEvent -> state.updateRecord(p.invocationId) {
|
||||
it.copy(
|
||||
status = ToolInvocationStatus.COMPLETED,
|
||||
receipt = p.receipt,
|
||||
completedAt = event.metadata.timestamp
|
||||
)
|
||||
}
|
||||
is ToolExecutionFailedEvent -> state.updateRecord(p.invocationId) {
|
||||
it.copy(status = ToolInvocationStatus.FAILED, completedAt = event.metadata.timestamp)
|
||||
}
|
||||
is ToolExecutionRejectedEvent -> state.updateRecord(p.invocationId) {
|
||||
it.copy(status = ToolInvocationStatus.REJECTED, completedAt = event.metadata.timestamp)
|
||||
}
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
|
||||
private fun ToolState.updateRecord(
|
||||
invocationId: ToolInvocationId,
|
||||
transform: (ToolInvocationRecord) -> ToolInvocationRecord
|
||||
): ToolState = copy(
|
||||
invocations = invocations.map { if (it.invocationId == invocationId) transform(it) else it }
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
import com.correx.core.tools.state.ToolState
|
||||
|
||||
class DefaultToolRepository(private val replayer: EventReplayer<ToolState>) {
|
||||
fun getToolState(sessionId: SessionId): ToolState = replayer.rebuild(sessionId)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
object Module
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
import com.correx.core.tools.state.ToolState
|
||||
|
||||
class ToolProjector(private val reducer: ToolReducer) : Projection<ToolState> {
|
||||
override fun initial(): ToolState = ToolState()
|
||||
|
||||
override fun apply(state: ToolState, event: StoredEvent): ToolState = reducer.reduce(state, event)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.tools.state.ToolState
|
||||
|
||||
interface ToolReducer {
|
||||
fun reduce(state: ToolState, event: StoredEvent): ToolState
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import java.nio.file.Path
|
||||
|
||||
interface FileAffectingTool : Tool {
|
||||
fun affectedPaths(request: ToolRequest): Set<Path>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
|
||||
interface Tool {
|
||||
val name: String
|
||||
val tier: Tier
|
||||
val requiredCapabilities: Set<ToolCapability>
|
||||
fun validateRequest(request: ToolRequest): ValidationResult
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
enum class ToolCapability {
|
||||
FILE_READ,
|
||||
FILE_WRITE,
|
||||
NETWORK_ACCESS,
|
||||
SHELL_EXEC,
|
||||
PROCESS_SPAWN
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
|
||||
interface ToolExecutor {
|
||||
suspend fun execute(request: ToolRequest): ToolResult
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface ToolResult {
|
||||
@Serializable
|
||||
data class Success(
|
||||
val invocationId: ToolInvocationId,
|
||||
val output: String,
|
||||
val exitCode: Int = 0,
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
) : ToolResult
|
||||
|
||||
@Serializable
|
||||
data class Failure(
|
||||
val invocationId: ToolInvocationId,
|
||||
val reason: String,
|
||||
val recoverable: Boolean,
|
||||
) : ToolResult
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
sealed interface ValidationResult {
|
||||
data object Valid : ValidationResult
|
||||
data class Invalid(val reason: String) : ValidationResult
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.tools.registry
|
||||
|
||||
import com.correx.core.tools.contract.Tool
|
||||
|
||||
interface ToolRegistry {
|
||||
fun resolve(name: String): Tool?
|
||||
fun all(): List<Tool>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.correx.core.tools.state
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ToolInvocationRecord(
|
||||
val invocationId: ToolInvocationId,
|
||||
val toolName: String,
|
||||
val tier: Tier,
|
||||
val request: ToolRequest,
|
||||
val status: ToolInvocationStatus,
|
||||
val receipt: ToolReceipt? = null,
|
||||
val requestedAt: Instant,
|
||||
val completedAt: Instant? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.tools.state
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ToolInvocationStatus {
|
||||
REQUESTED,
|
||||
STARTED,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.tools.state
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ToolState(
|
||||
val invocations: List<ToolInvocationRecord> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.correx.core.tools
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||
import com.correx.core.events.events.ToolExecutionRejectedEvent
|
||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.ToolInvokedEvent
|
||||
import com.correx.core.events.events.ToolReceipt
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tools.state.ToolInvocationStatus
|
||||
import com.correx.core.tools.state.ToolState
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DefaultToolReducerTest {
|
||||
|
||||
private val reducer = DefaultToolReducer()
|
||||
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||
private val invocationId = ToolInvocationId("inv-1")
|
||||
private val sessionId = SessionId("s-1")
|
||||
private val stageId = StageId("st-1")
|
||||
|
||||
private val request = ToolRequest(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
toolName = "echo"
|
||||
)
|
||||
|
||||
private val receipt = ToolReceipt(
|
||||
invocationId = invocationId,
|
||||
toolName = "echo",
|
||||
exitCode = 0,
|
||||
outputSummary = "done",
|
||||
durationMs = 50L,
|
||||
tier = Tier.T0,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
private fun storedEvent(payload: com.correx.core.events.events.EventPayload): StoredEvent =
|
||||
StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e-1"),
|
||||
sessionId = sessionId,
|
||||
timestamp = timestamp,
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null
|
||||
),
|
||||
sequence = 1L,
|
||||
payload = payload
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `ToolInvocationRequestedEvent appends REQUESTED record`() {
|
||||
val event = storedEvent(
|
||||
ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request)
|
||||
)
|
||||
val state = reducer.reduce(ToolState(), event)
|
||||
assertEquals(1, state.invocations.size)
|
||||
assertEquals(ToolInvocationStatus.REQUESTED, state.invocations[0].status)
|
||||
assertEquals(invocationId, state.invocations[0].invocationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionStartedEvent transitions record to STARTED`() {
|
||||
var state = reducer.reduce(
|
||||
ToolState(),
|
||||
storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request))
|
||||
)
|
||||
state = reducer.reduce(state, storedEvent(ToolExecutionStartedEvent(invocationId, sessionId, "echo")))
|
||||
assertEquals(ToolInvocationStatus.STARTED, state.invocations[0].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionCompletedEvent transitions to COMPLETED with receipt and completedAt`() {
|
||||
var state = reducer.reduce(
|
||||
ToolState(),
|
||||
storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request))
|
||||
)
|
||||
state = reducer.reduce(state, storedEvent(ToolExecutionStartedEvent(invocationId, sessionId, "echo")))
|
||||
state = reducer.reduce(
|
||||
state,
|
||||
storedEvent(ToolExecutionCompletedEvent(invocationId, sessionId, "echo", receipt))
|
||||
)
|
||||
val record = state.invocations[0]
|
||||
assertEquals(ToolInvocationStatus.COMPLETED, record.status)
|
||||
assertNotNull(record.receipt)
|
||||
assertEquals(0, record.receipt!!.exitCode)
|
||||
assertNotNull(record.completedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionFailedEvent transitions to FAILED and sets completedAt`() {
|
||||
var state = reducer.reduce(
|
||||
ToolState(),
|
||||
storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request))
|
||||
)
|
||||
state = reducer.reduce(state, storedEvent(ToolExecutionFailedEvent(invocationId, sessionId, "echo", "timeout")))
|
||||
val record = state.invocations[0]
|
||||
assertEquals(ToolInvocationStatus.FAILED, record.status)
|
||||
assertNull(record.receipt)
|
||||
assertNotNull(record.completedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolExecutionRejectedEvent transitions to REJECTED and sets completedAt`() {
|
||||
var state = reducer.reduce(
|
||||
ToolState(),
|
||||
storedEvent(ToolInvocationRequestedEvent(invocationId, sessionId, stageId, "echo", Tier.T0, request))
|
||||
)
|
||||
state = reducer.reduce(
|
||||
state,
|
||||
storedEvent(ToolExecutionRejectedEvent(invocationId, sessionId, "echo", Tier.T0, "tier denied"))
|
||||
)
|
||||
val record = state.invocations[0]
|
||||
assertEquals(ToolInvocationStatus.REJECTED, record.status)
|
||||
assertNotNull(record.completedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated event passes through unchanged`() {
|
||||
val state = ToolState()
|
||||
val result = reducer.reduce(state, storedEvent(ToolInvokedEvent("test-tool")))
|
||||
assertEquals(state, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.correx.core.tools.contract
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class ToolContractTest {
|
||||
|
||||
private val stubTool = object : Tool {
|
||||
override val name: String = "stub"
|
||||
override val tier: Tier = Tier.T0
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
|
||||
}
|
||||
|
||||
private val request = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("st-1"),
|
||||
toolName = "stub"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `stub tool returns Valid for any request`() {
|
||||
assertIs<ValidationResult.Valid>(stubTool.validateRequest(request))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ValidationResult Invalid carries reason`() {
|
||||
val result = ValidationResult.Invalid("missing required parameter")
|
||||
assertIs<ValidationResult.Invalid>(result)
|
||||
assertEquals("missing required parameter", result.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool exposes name tier and capabilities`() {
|
||||
assertEquals("stub", stubTool.name)
|
||||
assertEquals(Tier.T0, stubTool.tier)
|
||||
assertEquals(emptySet(), stubTool.requiredCapabilities)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.correx.core.tools.state
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToolStateTest {
|
||||
|
||||
private val request = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("st-1"),
|
||||
toolName = "echo"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial ToolState has empty invocations`() {
|
||||
assertTrue(ToolState().invocations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolInvocationRecord has null receipt and completedAt by default`() {
|
||||
val record = ToolInvocationRecord(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
toolName = "echo",
|
||||
tier = Tier.T0,
|
||||
request = request,
|
||||
status = ToolInvocationStatus.REQUESTED,
|
||||
requestedAt = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
assertNull(record.receipt)
|
||||
assertNull(record.completedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolState can accumulate multiple records`() {
|
||||
val base = ToolInvocationRecord(
|
||||
invocationId = ToolInvocationId("inv-1"),
|
||||
toolName = "echo",
|
||||
tier = Tier.T0,
|
||||
request = request,
|
||||
status = ToolInvocationStatus.REQUESTED,
|
||||
requestedAt = Instant.parse("2026-01-01T00:00:00Z")
|
||||
)
|
||||
val state = ToolState(
|
||||
invocations = listOf(base, base.copy(invocationId = ToolInvocationId("inv-2")))
|
||||
)
|
||||
assertEquals(2, state.invocations.size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user