feat(journal): core:journal module — decision journal projection

This commit is contained in:
2026-06-04 00:35:11 +04:00
parent 371e0df340
commit c21bbda85f
11 changed files with 193 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
plugins {
id 'java-library'
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
}
dependencies {
implementation(project(":core:events"))
testImplementation(project(":infrastructure:persistence"))
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,13 @@
package com.correx.core.journal
import com.correx.core.events.events.StoredEvent
import com.correx.core.journal.model.DecisionJournalState
import com.correx.core.sessions.projections.Projection
class DecisionJournalProjector(
private val reducer: DecisionJournalReducer,
) : Projection<DecisionJournalState> {
override fun initial(): DecisionJournalState = DecisionJournalState()
override fun apply(state: DecisionJournalState, event: StoredEvent): DecisionJournalState =
reducer.reduce(state, event)
}
@@ -0,0 +1,8 @@
package com.correx.core.journal
import com.correx.core.events.events.StoredEvent
import com.correx.core.journal.model.DecisionJournalState
interface DecisionJournalReducer {
fun reduce(state: DecisionJournalState, event: StoredEvent): DecisionJournalState
}
@@ -0,0 +1,14 @@
package com.correx.core.journal
import com.correx.core.journal.model.DecisionJournalState
class DecisionJournalRenderer(private val keepLast: Int = 40) {
fun render(state: DecisionJournalState): String {
if (state.records.isEmpty()) return ""
val shown = state.records.takeLast(keepLast)
return buildString {
appendLine("## Decision history (chronological — ground truth)")
shown.forEach { appendLine("- [${it.kind}] ${it.summary}") }
}.trimEnd()
}
}
@@ -0,0 +1,60 @@
package com.correx.core.journal
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.RetryAttemptedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.journal.model.DecisionJournalState
import com.correx.core.journal.model.DecisionKind
import com.correx.core.journal.model.DecisionRecord
class DefaultDecisionJournalReducer : DecisionJournalReducer {
override fun reduce(state: DecisionJournalState, event: StoredEvent): DecisionJournalState {
val record = when (val p = event.payload) {
is SteeringNoteAddedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.STEERING,
summary = "User steering: ${p.content}",
stageId = p.stageId?.value,
)
is ApprovalDecisionResolvedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.APPROVAL,
summary = buildString {
append("Approval ${p.outcome} (tier ${p.tier})")
p.reason?.let { append(": $it") }
p.userSteering?.let { append(" — steered: ${it.text}") }
},
)
is TransitionExecutedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.TRANSITION,
summary = "Advanced ${p.from.value}${p.to.value} (${p.transitionId.value})",
stageId = p.to.value,
)
is RetryAttemptedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.RETRY,
summary = "Retry ${p.attemptNumber}/${p.maxAttempts} of ${p.stageId.value}: ${p.failureReason}",
stageId = p.stageId.value,
)
is StageFailedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.FAILURE,
summary = "Stage ${p.stageId.value} failed: ${p.reason}",
stageId = p.stageId.value,
)
is ArtifactValidatedEvent -> DecisionRecord(
sequence = event.sessionSequence,
kind = DecisionKind.ARTIFACT,
summary = "Validated artifact ${p.artifactId.value} at ${p.stageId.value}",
stageId = p.stageId.value,
)
else -> null
}
return record?.let { state.copy(records = state.records + it) } ?: state
}
}
@@ -0,0 +1,11 @@
package com.correx.core.journal
import com.correx.core.events.types.SessionId
import com.correx.core.journal.model.DecisionJournalState
import com.correx.core.sessions.projections.replay.EventReplayer
class DefaultDecisionJournalRepository(
private val replayer: EventReplayer<DecisionJournalState>,
) {
fun getJournal(sessionId: SessionId): DecisionJournalState = replayer.rebuild(sessionId)
}
@@ -0,0 +1,8 @@
package com.correx.core.journal.model
import kotlinx.serialization.Serializable
@Serializable
data class DecisionJournalState(
val records: List<DecisionRecord> = emptyList(),
)
@@ -0,0 +1,19 @@
package com.correx.core.journal.model
import kotlinx.serialization.Serializable
enum class DecisionKind { STEERING, APPROVAL, TRANSITION, RETRY, FAILURE, ARTIFACT }
/**
* One distilled decision. [sequence] is the source event's sessionSequence, used for
* stable ordering. [summary] is a single human/agent-readable line. Bounded by the
* count of *distinct decisions*, not raw events — only the six decision-bearing event
* types produce a record.
*/
@Serializable
data class DecisionRecord(
val sequence: Long,
val kind: DecisionKind,
val summary: String,
val stageId: String? = null,
)
+1
View File
@@ -27,6 +27,7 @@ include ':core:router'
include ':core:sessions'
include ':core:config'
include ':core:risk'
include ':core:journal'
include ':infrastructure:persistence'
include ':infrastructure:inference'
+1
View File
@@ -13,5 +13,6 @@ dependencies {
testImplementation(project(":core:kernel"))
testImplementation(project(":core:artifacts"))
testImplementation(project(":core:router"))
testImplementation(project(":core:journal"))
implementation(project(":testing:fixtures"))
}
@@ -0,0 +1,47 @@
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.model.DecisionKind
import com.correx.testing.fixtures.EventFixtures.stored
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DecisionJournalProjectorTest {
private val projector = DecisionJournalProjector(DefaultDecisionJournalReducer())
@Test
fun `steering and transition each produce one ordered record`() {
var s = projector.initial()
s = projector.apply(
s,
stored(
payload = SteeringNoteAddedEvent(SessionId("x"), "use jwt", StageId("plan")),
sequence = 1L,
),
)
s = projector.apply(
s,
stored(
payload = TransitionExecutedEvent(SessionId("x"), StageId("plan"), StageId("impl"), TransitionId("t1")),
sequence = 2L,
),
)
assertEquals(2, s.records.size)
assertEquals(DecisionKind.STEERING, s.records[0].kind)
assertEquals(DecisionKind.TRANSITION, s.records[1].kind)
}
@Test
fun `unrelated events are ignored`() {
val s0 = projector.initial()
val s1 = projector.apply(
s0,
stored(payload = SteeringNoteAddedEvent(SessionId("x"), "note")),
)
assertEquals(1, s1.records.size)
}
}