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:sessions'
|
||||||
include ':core:config'
|
include ':core:config'
|
||||||
include ':core:risk'
|
include ':core:risk'
|
||||||
|
include ':core:journal'
|
||||||
|
|
||||||
include ':infrastructure:persistence'
|
include ':infrastructure:persistence'
|
||||||
include ':infrastructure:inference'
|
include ':infrastructure:inference'
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ dependencies {
|
|||||||
testImplementation(project(":core:kernel"))
|
testImplementation(project(":core:kernel"))
|
||||||
testImplementation(project(":core:artifacts"))
|
testImplementation(project(":core:artifacts"))
|
||||||
testImplementation(project(":core:router"))
|
testImplementation(project(":core:router"))
|
||||||
|
testImplementation(project(":core:journal"))
|
||||||
implementation(project(":testing:fixtures"))
|
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