fix: harden event store, serialization, and grant engine; wire router chat
Event log integrity (sole source of truth): - SqliteEventStore: serialize append/appendAll under a Mutex, enable WAL + busy_timeout + synchronous=NORMAL, roll back on any Throwable. Replaces the app-computed `SELECT MAX(seq)+1` read-modify-write that dropped events under concurrent appends (race was invisible to CI: only the in-memory store and single-threaded :memory: tests were ever exercised). - Serialization: encodeDefaults + ignoreUnknownKeys, explicit @SerialName on all 46 EventPayload subclasses and event-referenced enum constants, @Serializable on RiskLevel/RiskAction. Guards the append-only log against silent corruption from future class renames, field removals, or default-value changes. Approval/grant security (Invariant: approvals cannot widen authority): - Tier authorization is now a ceiling (request.tier.level <= max granted) instead of set membership; SESSION grants bind to a specific toolName instead of matching everything; handleCreateGrant rejects empty/over-tier/scopeless grants. Router chat wiring (Phase 0-2): ChatInput round-trip, StartChatSession from IDLE, router-panel layout, workflows behind Ctrl+W. Tests: SqliteEventStore concurrency, serialization round-trip + discriminator stability + unknown-key tolerance, adversarial grant scope/tier; updated existing approval and reducer tests for the new grant semantics and StartChatSession effect.
This commit is contained in:
@@ -23,6 +23,7 @@ dependencies {
|
||||
implementation project(':apps:server')
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:approvals')
|
||||
implementation project(':core:router')
|
||||
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
|
||||
@@ -19,4 +19,5 @@ sealed class KeyEvent {
|
||||
data class CharInput(val ch: Char) : KeyEvent()
|
||||
object CursorLeft : KeyEvent()
|
||||
object CursorRight : KeyEvent()
|
||||
object ToggleWorkflows : KeyEvent()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.correx.apps.tui
|
||||
|
||||
import com.correx.apps.tui.components.approvalSurfaceWidget
|
||||
import com.correx.apps.tui.components.diffViewerWidget
|
||||
import com.correx.apps.tui.components.eventHistoryStripWidget
|
||||
import com.correx.apps.tui.components.filteredSessions
|
||||
import com.correx.apps.tui.components.inputBarWidget
|
||||
import com.correx.apps.tui.components.routerPanelWidget
|
||||
import com.correx.apps.tui.components.sessionListWidget
|
||||
@@ -16,7 +16,6 @@ import com.correx.apps.tui.reducer.EffectDispatcher
|
||||
import com.correx.apps.tui.reducer.RootReducer
|
||||
import com.correx.apps.tui.state.DisplayState
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import com.correx.apps.tui.state.ToolDisplayStatus
|
||||
import com.correx.apps.tui.state.displayState
|
||||
import com.correx.apps.tui.state.selectedPendingApproval
|
||||
import com.correx.apps.tui.ws.ConnectionEvent
|
||||
@@ -38,9 +37,10 @@ import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
private const val DEFAULT_PORT = 8080
|
||||
private const val STATUS_HEIGHT = 1
|
||||
private const val EVENT_STRIP_HEIGHT = 6
|
||||
private const val DIFF_HEIGHT = 8
|
||||
private const val WORKFLOW_HEIGHT = 6
|
||||
private const val INPUT_HEIGHT = 4
|
||||
private const val MAX_VISIBLE_SESSIONS = 7
|
||||
private const val SESSION_BORDER_PADDING = 2
|
||||
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
|
||||
|
||||
@@ -130,51 +130,16 @@ private fun render(frame: Frame, state: TuiState) {
|
||||
|
||||
private fun renderIdleLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val sessionCount = filteredSessions(state.sessions).size
|
||||
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
|
||||
val hasWorkflows = state.sessions.workflows.isNotEmpty()
|
||||
val vertRects = if (hasWorkflows) {
|
||||
Layout.vertical()
|
||||
.constraints(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.fill(),
|
||||
Constraint.length(WORKFLOW_HEIGHT),
|
||||
Constraint.length(INPUT_HEIGHT),
|
||||
)
|
||||
.split(area)
|
||||
} else {
|
||||
Layout.vertical()
|
||||
.constraints(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.fill(),
|
||||
Constraint.length(INPUT_HEIGHT),
|
||||
)
|
||||
.split(area)
|
||||
}
|
||||
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[0])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[1])
|
||||
if (hasWorkflows) {
|
||||
frame.renderWidget(workflowListWidget(state), vertRects[2])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[3])
|
||||
} else {
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[2])
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
val hasDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.diff != null
|
||||
|
||||
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.length(sessionListHeight),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
if (hasDiff) {
|
||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
||||
if (showWorkflows) {
|
||||
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
@@ -185,32 +150,51 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
|
||||
var rectIdx = 0
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
|
||||
if (showWorkflows) {
|
||||
frame.renderWidget(workflowListWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IDLE), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val sessionCount = filteredSessions(state.sessions).size
|
||||
val sessionListHeight = minOf(sessionCount, MAX_VISIBLE_SESSIONS) + SESSION_BORDER_PADDING
|
||||
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
Constraint.length(sessionListHeight),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
|
||||
val vertRects = Layout.vertical()
|
||||
.constraints(constraints)
|
||||
.split(area)
|
||||
|
||||
var rectIdx = 0
|
||||
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(sessionListWidget(state), vertRects[rectIdx++])
|
||||
if (state.eventStripVisible) {
|
||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
if (hasDiff) {
|
||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||
val area = frame.area()
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
val hasDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.diff != null
|
||||
|
||||
val constraints = mutableListOf(
|
||||
Constraint.length(STATUS_HEIGHT),
|
||||
)
|
||||
if (state.eventStripVisible) {
|
||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||
}
|
||||
if (hasDiff) {
|
||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
||||
}
|
||||
constraints.add(Constraint.fill())
|
||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||
|
||||
@@ -223,9 +207,6 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||
if (state.eventStripVisible) {
|
||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
if (hasDiff) {
|
||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
||||
}
|
||||
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
||||
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), vertRects[rectIdx])
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.correx.apps.tui.components
|
||||
|
||||
import com.correx.apps.tui.state.ToolDisplayStatus
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import dev.tamboui.style.Style
|
||||
import dev.tamboui.text.Line
|
||||
import dev.tamboui.text.Span
|
||||
import dev.tamboui.text.Text
|
||||
import dev.tamboui.widgets.block.Block
|
||||
import dev.tamboui.widgets.block.BorderType
|
||||
import dev.tamboui.widgets.block.Borders
|
||||
import dev.tamboui.widgets.block.Title
|
||||
import dev.tamboui.widgets.paragraph.Paragraph
|
||||
|
||||
private const val DIFF_MAX_LINES = 8
|
||||
private const val LINE_MAX_LENGTH = 100
|
||||
|
||||
private val dimStyle = Style.create().dim().gray()
|
||||
private val greenStyle = Style.create().green()
|
||||
private val redStyle = Style.create().red()
|
||||
private val yellowStyle = Style.create().yellow()
|
||||
|
||||
fun diffViewerWidget(state: TuiState): Paragraph {
|
||||
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
|
||||
|
||||
// Only show the diff for the most recently completed tool. If the last tool has no diff
|
||||
// (e.g. a shell command), the viewer is hidden — stale diffs from earlier tools are noise.
|
||||
val toolWithDiff = selectedSession?.tools
|
||||
?.lastOrNull { it.status == ToolDisplayStatus.COMPLETED }
|
||||
?.takeIf { it.diff != null }
|
||||
|
||||
val diffText = toolWithDiff?.diff ?: return Paragraph.builder().build()
|
||||
|
||||
val header = " ${toolWithDiff.name}"
|
||||
val headerLine = Line.from(Span.styled(header, Style.create().cyan()))
|
||||
|
||||
val diffLines = diffText.lines()
|
||||
val visible = diffLines.take(DIFF_MAX_LINES)
|
||||
val overflow = diffLines.size - DIFF_MAX_LINES
|
||||
|
||||
val contentLines = buildList<Line> {
|
||||
for (line in visible) {
|
||||
val truncated = if (line.length > LINE_MAX_LENGTH) {
|
||||
line.take(LINE_MAX_LENGTH - 3) + "..."
|
||||
} else {
|
||||
line
|
||||
}
|
||||
when {
|
||||
truncated.startsWith("@@") -> add(
|
||||
Line.from(Span.styled(truncated, yellowStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("+++") || truncated.startsWith("---") -> add(
|
||||
Line.from(Span.styled(truncated, dimStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("+") -> add(
|
||||
Line.from(Span.styled(truncated, greenStyle)),
|
||||
)
|
||||
|
||||
truncated.startsWith("-") -> add(
|
||||
Line.from(Span.styled(truncated, redStyle)),
|
||||
)
|
||||
|
||||
else -> add(Line.from(Span.raw(truncated)))
|
||||
}
|
||||
}
|
||||
if (overflow > 0) {
|
||||
add(Line.from(Span.styled(" ($overflow more lines...)", dimStyle)))
|
||||
}
|
||||
}
|
||||
|
||||
val lines = listOf(headerLine) + contentLines
|
||||
|
||||
val block = Block.builder()
|
||||
.title(Title.from(Span.styled("diff", Style.create().green())).centered())
|
||||
.borders(Borders.ALL)
|
||||
.borderType(BorderType.ROUNDED)
|
||||
.build()
|
||||
|
||||
return Paragraph.builder().text(Text.from(lines)).block(block).build()
|
||||
}
|
||||
@@ -26,5 +26,6 @@ sealed interface Action {
|
||||
data object CycleMode : Action
|
||||
data object CursorLeft : Action
|
||||
data object CursorRight : Action
|
||||
data object ToggleWorkflows : Action
|
||||
data object NoOp : Action
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ object KeyResolver {
|
||||
KeyEvent.Tab -> Action.CycleMode
|
||||
KeyEvent.Backspace -> Action.Backspace
|
||||
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
|
||||
KeyEvent.ToggleWorkflows -> Action.ToggleWorkflows
|
||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
event.isChar('l') -> KeyEvent.ReturnToSessionList
|
||||
event.isChar('e') -> KeyEvent.ToggleEventStrip
|
||||
event.isChar('h') -> KeyEvent.ShowPendingApproval
|
||||
event.isChar('w') -> KeyEvent.ToggleWorkflows
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,17 @@ object RootReducer {
|
||||
) ProviderType.LOCAL else ProviderType.REMOTE,
|
||||
)
|
||||
}
|
||||
msg is ServerMessage.RouterResponseMessage -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages + msg.content,
|
||||
)
|
||||
}
|
||||
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages +
|
||||
"--- diff from ${msg.toolName} ---",
|
||||
)
|
||||
}
|
||||
else -> withBgReset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import com.correx.apps.tui.state.ToolDisplayStatus
|
||||
import com.correx.apps.tui.state.ToolManifestEntry
|
||||
import com.correx.apps.tui.state.TuiEventEntry
|
||||
import com.correx.apps.tui.state.TuiToolRecord
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.router.ChatMode
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
@@ -59,19 +59,30 @@ object SessionsReducer {
|
||||
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
|
||||
)
|
||||
}
|
||||
|
||||
text.isNotBlank() -> {
|
||||
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
|
||||
sessions to listOf(
|
||||
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
|
||||
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
|
||||
)
|
||||
}
|
||||
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
displayState == DisplayState.IN_SESSION -> {
|
||||
// Chat send is blocked until Epic 14 (router integration).
|
||||
// History append is handled by RootReducer cross-field weave.
|
||||
sessions to emptyList()
|
||||
sessions to listOf(
|
||||
Effect.SendWs(
|
||||
ClientMessage.ChatInput(
|
||||
sessionId = SessionId(sessions.selectedId!!),
|
||||
text = inputText,
|
||||
mode = ChatMode.CHAT,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
@@ -90,6 +101,10 @@ object SessionsReducer {
|
||||
}
|
||||
}
|
||||
|
||||
is Action.ToggleWorkflows -> sessions.copy(
|
||||
workflowsVisible = !sessions.workflowsVisible,
|
||||
) to emptyList()
|
||||
|
||||
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
|
||||
else -> sessions to emptyList()
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ data class SessionsState(
|
||||
val workflows: List<WorkflowDto> = emptyList(),
|
||||
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
|
||||
val selectedWorkflowIndex: Int = -1,
|
||||
/** When true, the workflow list is shown in IDLE layout even when sessions exist. */
|
||||
val workflowsVisible: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -33,15 +33,16 @@ class RootReducerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in ROUTER mode emits StartSession effect and resets buffer`() {
|
||||
fun `SubmitInput in ROUTER mode emits StartChatSession effect and resets buffer`() {
|
||||
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow")
|
||||
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
|
||||
assertEquals(InputMode.ROUTER, state.inputMode)
|
||||
assertEquals("", state.inputBuffer)
|
||||
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
||||
assertEquals(1, wsEffects.size)
|
||||
val msg = wsEffects[0].message as ClientMessage.StartSession
|
||||
assertEquals("my-workflow", msg.workflowId)
|
||||
val msg = wsEffects[0].message as ClientMessage.StartChatSession
|
||||
assertEquals("my-workflow", msg.text)
|
||||
assertTrue(msg.sessionId.value.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user