From c21bbda85f586afaccdc909cf6bd5eea45abf154 Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 4 Jun 2026 00:35:11 +0400 Subject: [PATCH] =?UTF-8?q?feat(journal):=20core:journal=20module=20?= =?UTF-8?q?=E2=80=94=20decision=20journal=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/journal/build.gradle | 11 ++++ .../core/journal/DecisionJournalProjector.kt | 13 ++++ .../core/journal/DecisionJournalReducer.kt | 8 +++ .../core/journal/DecisionJournalRenderer.kt | 14 +++++ .../journal/DefaultDecisionJournalReducer.kt | 60 +++++++++++++++++++ .../DefaultDecisionJournalRepository.kt | 11 ++++ .../journal/model/DecisionJournalState.kt | 8 +++ .../core/journal/model/DecisionRecord.kt | 19 ++++++ settings.gradle | 1 + testing/projections/build.gradle | 1 + .../kotlin/DecisionJournalProjectorTest.kt | 47 +++++++++++++++ 11 files changed, 193 insertions(+) create mode 100644 core/journal/build.gradle create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalProjector.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalReducer.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalReducer.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalRepository.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionJournalState.kt create mode 100644 core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionRecord.kt create mode 100644 testing/projections/src/test/kotlin/DecisionJournalProjectorTest.kt diff --git a/core/journal/build.gradle b/core/journal/build.gradle new file mode 100644 index 00000000..8f941b38 --- /dev/null +++ b/core/journal/build.gradle @@ -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 } diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalProjector.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalProjector.kt new file mode 100644 index 00000000..d26bcdd4 --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalProjector.kt @@ -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 { + override fun initial(): DecisionJournalState = DecisionJournalState() + override fun apply(state: DecisionJournalState, event: StoredEvent): DecisionJournalState = + reducer.reduce(state, event) +} diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalReducer.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalReducer.kt new file mode 100644 index 00000000..40857f2f --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalReducer.kt @@ -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 +} diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt new file mode 100644 index 00000000..d1423d65 --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DecisionJournalRenderer.kt @@ -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() + } +} diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalReducer.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalReducer.kt new file mode 100644 index 00000000..56843d55 --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalReducer.kt @@ -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 + } +} diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalRepository.kt b/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalRepository.kt new file mode 100644 index 00000000..bbfa4c7b --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/DefaultDecisionJournalRepository.kt @@ -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, +) { + fun getJournal(sessionId: SessionId): DecisionJournalState = replayer.rebuild(sessionId) +} diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionJournalState.kt b/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionJournalState.kt new file mode 100644 index 00000000..4dae04b1 --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionJournalState.kt @@ -0,0 +1,8 @@ +package com.correx.core.journal.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DecisionJournalState( + val records: List = emptyList(), +) diff --git a/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionRecord.kt b/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionRecord.kt new file mode 100644 index 00000000..60adfb60 --- /dev/null +++ b/core/journal/src/main/kotlin/com/correx/core/journal/model/DecisionRecord.kt @@ -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, +) diff --git a/settings.gradle b/settings.gradle index 26974480..f10a760e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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' diff --git a/testing/projections/build.gradle b/testing/projections/build.gradle index 0b41a9d0..c62a84f5 100644 --- a/testing/projections/build.gradle +++ b/testing/projections/build.gradle @@ -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")) } \ No newline at end of file diff --git a/testing/projections/src/test/kotlin/DecisionJournalProjectorTest.kt b/testing/projections/src/test/kotlin/DecisionJournalProjectorTest.kt new file mode 100644 index 00000000..bd4bfde4 --- /dev/null +++ b/testing/projections/src/test/kotlin/DecisionJournalProjectorTest.kt @@ -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) + } +}