feat(journal): core:journal module — decision journal projection
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user