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:
@@ -47,11 +47,19 @@ sealed class ClientMessage {
|
|||||||
val permittedTiers: List<Tier>,
|
val permittedTiers: List<Tier>,
|
||||||
val reason: String,
|
val reason: String,
|
||||||
val expiresAt: Instant? = null,
|
val expiresAt: Instant? = null,
|
||||||
|
// Required for SESSION scope: binds this grant to a specific tool name.
|
||||||
|
val toolName: String? = null,
|
||||||
) : ClientMessage()
|
) : ClientMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Ping(val timestamp: Long) : ClientMessage()
|
data class Ping(val timestamp: Long) : ClientMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StartChatSession(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val text: String,
|
||||||
|
) : ClientMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
|
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,17 @@ import com.correx.apps.server.protocol.WorkflowDto
|
|||||||
import com.correx.apps.server.protocol.StageToolDecl
|
import com.correx.apps.server.protocol.StageToolDecl
|
||||||
import com.correx.apps.server.protocol.ToolDecl
|
import com.correx.apps.server.protocol.ToolDecl
|
||||||
import com.correx.core.approvals.GrantScope
|
import com.correx.core.approvals.GrantScope
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
import com.correx.core.events.events.ApprovalGrantCreatedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
import com.correx.core.events.types.GrantId
|
import com.correx.core.events.types.GrantId
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.inference.ProviderHealth
|
import com.correx.core.inference.ProviderHealth
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
@@ -140,11 +143,29 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
when (msg) {
|
when (msg) {
|
||||||
is ClientMessage.Ping -> Unit
|
is ClientMessage.Ping -> Unit
|
||||||
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
||||||
|
is ClientMessage.StartChatSession -> handleStartChatSession(session, msg, sendFrame)
|
||||||
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||||
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
is ClientMessage.ChatInput -> {
|
||||||
|
runCatching {
|
||||||
|
module.routerFacade.onUserInput(msg.sessionId, msg.text, msg.mode)
|
||||||
|
}.onSuccess { response ->
|
||||||
|
sendFrame(ServerMessage.RouterResponseMessage(
|
||||||
|
sessionId = msg.sessionId,
|
||||||
|
content = response.content,
|
||||||
|
steeringEmitted = response.steeringEmitted,
|
||||||
|
))
|
||||||
|
}.onFailure {
|
||||||
|
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||||
|
sendFrame(ServerMessage.ProtocolError(
|
||||||
|
message = "Router error: ${it.message}",
|
||||||
|
sequence = null,
|
||||||
|
sessionSequence = null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +209,31 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
msg: ClientMessage.CreateGrant,
|
msg: ClientMessage.CreateGrant,
|
||||||
sendFrame: suspend (ServerMessage) -> Unit,
|
sendFrame: suspend (ServerMessage) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Validate: tiers must be non-empty and bounded to T2 (server-side ceiling).
|
||||||
|
// T3/T4 are destructive/escalated tiers that must never be auto-approved via grants.
|
||||||
|
if (msg.permittedTiers.isEmpty()) {
|
||||||
|
sendFrame(errorResponse("CreateGrant: permittedTiers must not be empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val maxGrantableTier = Tier.T2
|
||||||
|
val overBroad = msg.permittedTiers.filter { it.level > maxGrantableTier.level }
|
||||||
|
if (overBroad.isNotEmpty()) {
|
||||||
|
sendFrame(errorResponse("CreateGrant: tiers ${overBroad.map { it.name }} exceed maximum grantable tier ${maxGrantableTier.name}"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val scope = when (msg.scope) {
|
val scope = when (msg.scope) {
|
||||||
GrantScopeDto.SESSION -> {
|
GrantScopeDto.SESSION -> {
|
||||||
if (msg.stageId != null) {
|
if (msg.stageId != null) {
|
||||||
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
sendFrame(errorResponse("CreateGrant: SESSION scope must not include stageId"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
GrantScope.SESSION
|
// Require toolName — blanket session grants (no operation scope) are rejected.
|
||||||
|
val toolName = msg.toolName?.takeIf { it.isNotBlank() } ?: run {
|
||||||
|
sendFrame(errorResponse("CreateGrant: SESSION scope requires toolName to prevent blanket approval"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
GrantScope.SESSION(toolName)
|
||||||
}
|
}
|
||||||
GrantScopeDto.STAGE -> {
|
GrantScopeDto.STAGE -> {
|
||||||
val sid = msg.stageId ?: run {
|
val sid = msg.stageId ?: run {
|
||||||
@@ -222,11 +261,66 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
sessionId = msg.sessionId,
|
sessionId = msg.sessionId,
|
||||||
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
stageId = (scope as? GrantScope.STAGE)?.stageId,
|
||||||
projectId = null,
|
projectId = null,
|
||||||
|
toolName = (scope as? GrantScope.SESSION)?.toolName,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
module.eventStore.append(event)
|
module.eventStore.append(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun handleStartChatSession(
|
||||||
|
session: DefaultWebSocketServerSession,
|
||||||
|
msg: ClientMessage.StartChatSession,
|
||||||
|
sendFrame: suspend (ServerMessage) -> Unit,
|
||||||
|
) {
|
||||||
|
val sessionId = msg.sessionId
|
||||||
|
log.info("starting chat session={}", sessionId.value)
|
||||||
|
|
||||||
|
// Emit WorkflowStartedEvent for the new session (no graph, no orchestrator).
|
||||||
|
val event = NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = WorkflowStartedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
workflowId = "chat",
|
||||||
|
startStageId = StageId("none"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
module.eventStore.append(event)
|
||||||
|
|
||||||
|
// Send SessionStarted so the TUI creates the session entry immediately.
|
||||||
|
val started = ServerMessage.SessionStarted(
|
||||||
|
sessionId = sessionId,
|
||||||
|
workflowId = "chat",
|
||||||
|
sequence = 0L,
|
||||||
|
sessionSequence = 0L,
|
||||||
|
)
|
||||||
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||||
|
|
||||||
|
// Call router and send response.
|
||||||
|
runCatching {
|
||||||
|
module.routerFacade.onUserInput(sessionId, msg.text)
|
||||||
|
}.onSuccess { response ->
|
||||||
|
sendFrame(ServerMessage.RouterResponseMessage(
|
||||||
|
sessionId = sessionId,
|
||||||
|
content = response.content,
|
||||||
|
steeringEmitted = response.steeringEmitted,
|
||||||
|
))
|
||||||
|
}.onFailure {
|
||||||
|
log.error("routerFacade.onUserInput failed: {}", it.message)
|
||||||
|
sendFrame(ServerMessage.ProtocolError(
|
||||||
|
message = "Router error: ${it.message}",
|
||||||
|
sequence = null,
|
||||||
|
sessionSequence = null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun handleStartSession(
|
private suspend fun handleStartSession(
|
||||||
session: DefaultWebSocketServerSession,
|
session: DefaultWebSocketServerSession,
|
||||||
msg: ClientMessage.StartSession,
|
msg: ClientMessage.StartSession,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies {
|
|||||||
implementation project(':apps:server')
|
implementation project(':apps:server')
|
||||||
implementation project(':core:events')
|
implementation project(':core:events')
|
||||||
implementation project(':core:approvals')
|
implementation project(':core:approvals')
|
||||||
|
implementation project(':core:router')
|
||||||
|
|
||||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ sealed class KeyEvent {
|
|||||||
data class CharInput(val ch: Char) : KeyEvent()
|
data class CharInput(val ch: Char) : KeyEvent()
|
||||||
object CursorLeft : KeyEvent()
|
object CursorLeft : KeyEvent()
|
||||||
object CursorRight : KeyEvent()
|
object CursorRight : KeyEvent()
|
||||||
|
object ToggleWorkflows : KeyEvent()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.correx.apps.tui
|
package com.correx.apps.tui
|
||||||
|
|
||||||
import com.correx.apps.tui.components.approvalSurfaceWidget
|
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.eventHistoryStripWidget
|
||||||
|
import com.correx.apps.tui.components.filteredSessions
|
||||||
import com.correx.apps.tui.components.inputBarWidget
|
import com.correx.apps.tui.components.inputBarWidget
|
||||||
import com.correx.apps.tui.components.routerPanelWidget
|
import com.correx.apps.tui.components.routerPanelWidget
|
||||||
import com.correx.apps.tui.components.sessionListWidget
|
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.reducer.RootReducer
|
||||||
import com.correx.apps.tui.state.DisplayState
|
import com.correx.apps.tui.state.DisplayState
|
||||||
import com.correx.apps.tui.state.TuiState
|
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.displayState
|
||||||
import com.correx.apps.tui.state.selectedPendingApproval
|
import com.correx.apps.tui.state.selectedPendingApproval
|
||||||
import com.correx.apps.tui.ws.ConnectionEvent
|
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 DEFAULT_PORT = 8080
|
||||||
private const val STATUS_HEIGHT = 1
|
private const val STATUS_HEIGHT = 1
|
||||||
private const val EVENT_STRIP_HEIGHT = 6
|
private const val EVENT_STRIP_HEIGHT = 6
|
||||||
private const val DIFF_HEIGHT = 8
|
|
||||||
private const val WORKFLOW_HEIGHT = 6
|
private const val WORKFLOW_HEIGHT = 6
|
||||||
private const val INPUT_HEIGHT = 4
|
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")
|
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) {
|
private fun renderIdleLayout(frame: Frame, state: TuiState) {
|
||||||
val area = frame.area()
|
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 hasWorkflows = state.sessions.workflows.isNotEmpty()
|
||||||
val vertRects = if (hasWorkflows) {
|
val showWorkflows = hasWorkflows && (state.sessions.workflowsVisible || state.sessions.sessions.isEmpty())
|
||||||
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 constraints = mutableListOf(
|
val constraints = mutableListOf(
|
||||||
Constraint.length(STATUS_HEIGHT),
|
Constraint.length(STATUS_HEIGHT),
|
||||||
|
Constraint.length(sessionListHeight),
|
||||||
)
|
)
|
||||||
if (state.eventStripVisible) {
|
if (showWorkflows) {
|
||||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
constraints.add(Constraint.length(WORKFLOW_HEIGHT))
|
||||||
}
|
|
||||||
if (hasDiff) {
|
|
||||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
|
||||||
}
|
}
|
||||||
constraints.add(Constraint.fill())
|
constraints.add(Constraint.fill())
|
||||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||||
@@ -185,32 +150,51 @@ private fun renderInSessionLayout(frame: Frame, state: TuiState) {
|
|||||||
|
|
||||||
var rectIdx = 0
|
var rectIdx = 0
|
||||||
frame.renderWidget(statusBarWidget(state), vertRects[rectIdx++])
|
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) {
|
if (state.eventStripVisible) {
|
||||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||||
}
|
}
|
||||||
if (hasDiff) {
|
|
||||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
|
||||||
}
|
|
||||||
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(routerPanelWidget(state), vertRects[rectIdx++])
|
||||||
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
|
frame.renderWidget(inputBarWidget(state, DisplayState.IN_SESSION), vertRects[rectIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
||||||
val area = frame.area()
|
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(
|
val constraints = mutableListOf(
|
||||||
Constraint.length(STATUS_HEIGHT),
|
Constraint.length(STATUS_HEIGHT),
|
||||||
)
|
)
|
||||||
if (state.eventStripVisible) {
|
if (state.eventStripVisible) {
|
||||||
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
constraints.add(Constraint.length(EVENT_STRIP_HEIGHT))
|
||||||
}
|
}
|
||||||
if (hasDiff) {
|
|
||||||
constraints.add(Constraint.length(DIFF_HEIGHT))
|
|
||||||
}
|
|
||||||
constraints.add(Constraint.fill())
|
constraints.add(Constraint.fill())
|
||||||
constraints.add(Constraint.length(INPUT_HEIGHT))
|
constraints.add(Constraint.length(INPUT_HEIGHT))
|
||||||
|
|
||||||
@@ -223,9 +207,6 @@ private fun renderApprovalLayout(frame: Frame, state: TuiState) {
|
|||||||
if (state.eventStripVisible) {
|
if (state.eventStripVisible) {
|
||||||
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(eventHistoryStripWidget(state), vertRects[rectIdx++])
|
||||||
}
|
}
|
||||||
if (hasDiff) {
|
|
||||||
frame.renderWidget(diffViewerWidget(state), vertRects[rectIdx++])
|
|
||||||
}
|
|
||||||
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
frame.renderWidget(approvalSurfaceWidget(state), vertRects[rectIdx++])
|
||||||
frame.renderWidget(inputBarWidget(state, DisplayState.APPROVAL), 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 CycleMode : Action
|
||||||
data object CursorLeft : Action
|
data object CursorLeft : Action
|
||||||
data object CursorRight : Action
|
data object CursorRight : Action
|
||||||
|
data object ToggleWorkflows : Action
|
||||||
data object NoOp : Action
|
data object NoOp : Action
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ object KeyResolver {
|
|||||||
KeyEvent.Tab -> Action.CycleMode
|
KeyEvent.Tab -> Action.CycleMode
|
||||||
KeyEvent.Backspace -> Action.Backspace
|
KeyEvent.Backspace -> Action.Backspace
|
||||||
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
|
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
|
||||||
|
KeyEvent.ToggleWorkflows -> Action.ToggleWorkflows
|
||||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
|||||||
event.isChar('l') -> KeyEvent.ReturnToSessionList
|
event.isChar('l') -> KeyEvent.ReturnToSessionList
|
||||||
event.isChar('e') -> KeyEvent.ToggleEventStrip
|
event.isChar('e') -> KeyEvent.ToggleEventStrip
|
||||||
event.isChar('h') -> KeyEvent.ShowPendingApproval
|
event.isChar('h') -> KeyEvent.ShowPendingApproval
|
||||||
|
event.isChar('w') -> KeyEvent.ToggleWorkflows
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ object RootReducer {
|
|||||||
) ProviderType.LOCAL else ProviderType.REMOTE,
|
) 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
|
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.ToolManifestEntry
|
||||||
import com.correx.apps.tui.state.TuiEventEntry
|
import com.correx.apps.tui.state.TuiEventEntry
|
||||||
import com.correx.apps.tui.state.TuiToolRecord
|
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.events.types.SessionId
|
||||||
|
import com.correx.core.router.ChatMode
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -59,19 +59,30 @@ object SessionsReducer {
|
|||||||
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
|
Effect.SendWs(ClientMessage.StartSession(workflowId = wf.workflowId, config = null)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
text.isNotBlank() -> {
|
text.isNotBlank() -> {
|
||||||
|
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
|
||||||
sessions to listOf(
|
sessions to listOf(
|
||||||
Effect.SendWs(ClientMessage.StartSession(workflowId = text, config = null)),
|
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> sessions to emptyList()
|
else -> sessions to emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
displayState == DisplayState.IN_SESSION -> {
|
displayState == DisplayState.IN_SESSION -> {
|
||||||
// Chat send is blocked until Epic 14 (router integration).
|
sessions to listOf(
|
||||||
// History append is handled by RootReducer cross-field weave.
|
Effect.SendWs(
|
||||||
sessions to emptyList()
|
ClientMessage.ChatInput(
|
||||||
|
sessionId = SessionId(sessions.selectedId!!),
|
||||||
|
text = inputText,
|
||||||
|
mode = ChatMode.CHAT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> sessions to emptyList()
|
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)
|
is Action.ServerEventReceived -> applyServerMessage(sessions, action.message, clock)
|
||||||
else -> sessions to emptyList()
|
else -> sessions to emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ data class SessionsState(
|
|||||||
val workflows: List<WorkflowDto> = emptyList(),
|
val workflows: List<WorkflowDto> = emptyList(),
|
||||||
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
|
/** Index into [workflows] when focus is in the workflow picker; -1 = no workflow selected. */
|
||||||
val selectedWorkflowIndex: Int = -1,
|
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
|
@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 initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "my-workflow")
|
||||||
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
|
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
|
||||||
assertEquals(InputMode.ROUTER, state.inputMode)
|
assertEquals(InputMode.ROUTER, state.inputMode)
|
||||||
assertEquals("", state.inputBuffer)
|
assertEquals("", state.inputBuffer)
|
||||||
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
||||||
assertEquals(1, wsEffects.size)
|
assertEquals(1, wsEffects.size)
|
||||||
val msg = wsEffects[0].message as ClientMessage.StartSession
|
val msg = wsEffects[0].message as ClientMessage.StartChatSession
|
||||||
assertEquals("my-workflow", msg.workflowId)
|
assertEquals("my-workflow", msg.text)
|
||||||
|
assertTrue(msg.sessionId.value.isNotBlank())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+15
-7
@@ -26,8 +26,10 @@ class DefaultApprovalEngine : ApprovalEngine {
|
|||||||
|
|
||||||
val matchingGrant = grants.firstOrNull { grant ->
|
val matchingGrant = grants.firstOrNull { grant ->
|
||||||
!isExpired(grant, now)
|
!isExpired(grant, now)
|
||||||
&& scopeMatches(grant.scope, context)
|
&& scopeMatches(grant.scope, context, request.toolName)
|
||||||
&& request.tier in grant.permittedTiers
|
// Ceiling semantics: grant authorizes up to its highest tier, not an exact set.
|
||||||
|
&& grant.permittedTiers.isNotEmpty()
|
||||||
|
&& request.tier.level <= grant.permittedTiers.maxOf { it.level }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchingGrant != null) {
|
if (matchingGrant != null) {
|
||||||
@@ -83,9 +85,15 @@ class DefaultApprovalEngine : ApprovalEngine {
|
|||||||
private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean =
|
private fun isExpired(grant: ApprovalGrant, now: Instant): Boolean =
|
||||||
grant.expiresAt != null && grant.expiresAt <= now
|
grant.expiresAt != null && grant.expiresAt <= now
|
||||||
|
|
||||||
private fun scopeMatches(scope: GrantScope, context: ApprovalContext): Boolean = when (scope) {
|
private fun scopeMatches(scope: GrantScope, context: ApprovalContext, requestToolName: String?): Boolean =
|
||||||
is GrantScope.SESSION -> true
|
when (scope) {
|
||||||
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
// SESSION grants are scoped to a specific tool name.
|
||||||
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
|
// A null toolName on either side means the binding is absent; treat as no-match
|
||||||
}
|
// to prevent a legacy/malformed grant from becoming a blanket approval.
|
||||||
|
is GrantScope.SESSION -> scope.toolName != null
|
||||||
|
&& requestToolName != null
|
||||||
|
&& scope.toolName == requestToolName
|
||||||
|
is GrantScope.STAGE -> context.identity.stageId == scope.stageId
|
||||||
|
is GrantScope.PROJECT -> context.identity.projectId == scope.projectId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -68,7 +68,7 @@ class DefaultApprovalReducerTest {
|
|||||||
val grantId = GrantId("grant-1")
|
val grantId = GrantId("grant-1")
|
||||||
val payload = ApprovalGrantCreatedEvent(
|
val payload = ApprovalGrantCreatedEvent(
|
||||||
grantId = grantId,
|
grantId = grantId,
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||||
reason = "user approved",
|
reason = "user approved",
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
@@ -81,7 +81,7 @@ class DefaultApprovalReducerTest {
|
|||||||
|
|
||||||
assertTrue(state.grants.containsKey(grantId))
|
assertTrue(state.grants.containsKey(grantId))
|
||||||
assertEquals(grantId, state.grants[grantId]?.id)
|
assertEquals(grantId, state.grants[grantId]?.id)
|
||||||
assertEquals(GrantScope.SESSION, state.grants[grantId]?.scope)
|
assertEquals(GrantScope.SESSION(), state.grants[grantId]?.scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -90,7 +90,7 @@ class DefaultApprovalReducerTest {
|
|||||||
|
|
||||||
val addPayload = ApprovalGrantCreatedEvent(
|
val addPayload = ApprovalGrantCreatedEvent(
|
||||||
grantId = grantId,
|
grantId = grantId,
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T1),
|
permittedTiers = setOf(Tier.T1),
|
||||||
reason = "granted",
|
reason = "granted",
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ class DefaultApprovalRepositoryTest {
|
|||||||
metadata = metadata(sessionId, "event-1"),
|
metadata = metadata(sessionId, "event-1"),
|
||||||
payload = ApprovalGrantCreatedEvent(
|
payload = ApprovalGrantCreatedEvent(
|
||||||
grantId = grantId,
|
grantId = grantId,
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T1, Tier.T2),
|
permittedTiers = setOf(Tier.T1, Tier.T2),
|
||||||
reason = "approved",
|
reason = "approved",
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package com.correx.core.approvals
|
package com.correx.core.approvals
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ApprovalOutcome {
|
enum class ApprovalOutcome {
|
||||||
APPROVED,
|
@SerialName("APPROVED") APPROVED,
|
||||||
REJECTED,
|
@SerialName("REJECTED") REJECTED,
|
||||||
AUTO_APPROVED,
|
@SerialName("AUTO_APPROVED") AUTO_APPROVED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed interface GrantScope {
|
sealed interface GrantScope {
|
||||||
@Serializable data object SESSION : GrantScope
|
// toolName binds this grant to a specific operation kind (e.g. "shell", "write_file").
|
||||||
|
// A null toolName is rejected at grant-creation time; it is kept nullable here only
|
||||||
|
// for backward-compatible deserialization of legacy events.
|
||||||
|
@Serializable data class SESSION(val toolName: String? = null) : GrantScope
|
||||||
@Serializable data class STAGE(val stageId: StageId) : GrantScope
|
@Serializable data class STAGE(val stageId: StageId) : GrantScope
|
||||||
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
|
@Serializable data class PROJECT(val projectId: ProjectId) : GrantScope
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package com.correx.core.approvals
|
package com.correx.core.approvals
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class Tier(val level: Int) {
|
enum class Tier(val level: Int) {
|
||||||
T0(0),
|
@SerialName("T0") T0(0),
|
||||||
T1(1),
|
@SerialName("T1") T1(1),
|
||||||
T2(2),
|
@SerialName("T2") T2(2),
|
||||||
T3(3),
|
@SerialName("T3") T3(3),
|
||||||
T4(4)
|
@SerialName("T4") T4(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level
|
fun Tier.isAtLeast(other: Tier): Boolean = this.level >= other.level
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import com.correx.core.events.types.SessionId
|
|||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ValidationReportId
|
import com.correx.core.events.types.ValidationReportId
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ApprovalRequested")
|
||||||
data class ApprovalRequestedEvent(
|
data class ApprovalRequestedEvent(
|
||||||
val requestId: ApprovalRequestId,
|
val requestId: ApprovalRequestId,
|
||||||
val tier: Tier,
|
val tier: Tier,
|
||||||
@@ -31,6 +33,7 @@ data class ApprovalRequestedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ApprovalDecisionResolved")
|
||||||
data class ApprovalDecisionResolvedEvent(
|
data class ApprovalDecisionResolvedEvent(
|
||||||
val decisionId: ApprovalDecisionId,
|
val decisionId: ApprovalDecisionId,
|
||||||
val requestId: ApprovalRequestId,
|
val requestId: ApprovalRequestId,
|
||||||
@@ -43,6 +46,7 @@ data class ApprovalDecisionResolvedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ApprovalGrantCreated")
|
||||||
data class ApprovalGrantCreatedEvent(
|
data class ApprovalGrantCreatedEvent(
|
||||||
val grantId: GrantId,
|
val grantId: GrantId,
|
||||||
val scope: GrantScope,
|
val scope: GrantScope,
|
||||||
@@ -52,9 +56,13 @@ data class ApprovalGrantCreatedEvent(
|
|||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId?,
|
val stageId: StageId?,
|
||||||
val projectId: ProjectId?,
|
val projectId: ProjectId?,
|
||||||
|
// Operation identity — must match DomainApprovalRequest.toolName for SESSION grants.
|
||||||
|
// Defaulted null for backward-compatible deserialization; new events always carry a value.
|
||||||
|
@SerialName("toolName") val toolName: String? = null,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ApprovalGrantExpired")
|
||||||
data class ApprovalGrantExpiredEvent(
|
data class ApprovalGrantExpiredEvent(
|
||||||
val grantId: GrantId,
|
val grantId: GrantId,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import com.correx.core.events.types.ArtifactId
|
|||||||
import com.correx.core.events.types.ArtifactRelationshipType
|
import com.correx.core.events.types.ArtifactRelationshipType
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactValidating")
|
||||||
data class ArtifactValidatingEvent(
|
data class ArtifactValidatingEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -14,6 +16,7 @@ data class ArtifactValidatingEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactValidated")
|
||||||
data class ArtifactValidatedEvent(
|
data class ArtifactValidatedEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -21,6 +24,7 @@ data class ArtifactValidatedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactSuperseded")
|
||||||
data class ArtifactSupersededEvent(
|
data class ArtifactSupersededEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val supersededById: ArtifactId,
|
val supersededById: ArtifactId,
|
||||||
@@ -29,6 +33,7 @@ data class ArtifactSupersededEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactRelationshipAdded")
|
||||||
data class ArtifactRelationshipAddedEvent(
|
data class ArtifactRelationshipAddedEvent(
|
||||||
val sourceId: ArtifactId,
|
val sourceId: ArtifactId,
|
||||||
val targetId: ArtifactId,
|
val targetId: ArtifactId,
|
||||||
@@ -37,6 +42,7 @@ data class ArtifactRelationshipAddedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactRejected")
|
||||||
data class ArtifactRejectedEvent(
|
data class ArtifactRejectedEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -45,6 +51,7 @@ data class ArtifactRejectedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactCreated")
|
||||||
data class ArtifactCreatedEvent(
|
data class ArtifactCreatedEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -53,6 +60,7 @@ data class ArtifactCreatedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ArtifactArchived")
|
||||||
data class ArtifactArchivedEvent(
|
data class ArtifactArchivedEvent(
|
||||||
val artifactId: ArtifactId,
|
val artifactId: ArtifactId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.correx.core.events.events
|
|||||||
import com.correx.core.events.types.ContextPackId
|
import com.correx.core.events.types.ContextPackId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("LayerTruncated")
|
||||||
data class LayerTruncatedEvent(
|
data class LayerTruncatedEvent(
|
||||||
val contextPackId: ContextPackId,
|
val contextPackId: ContextPackId,
|
||||||
val layer: String,
|
val layer: String,
|
||||||
@@ -14,12 +16,14 @@ data class LayerTruncatedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ContextBuildingStarted")
|
||||||
data class ContextBuildingStartedEvent(
|
data class ContextBuildingStartedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ContextBuildingFailed")
|
||||||
data class ContextBuildingFailedEvent(
|
data class ContextBuildingFailedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -28,12 +32,14 @@ data class ContextBuildingFailedEvent(
|
|||||||
|
|
||||||
// Emitted during replay when a session ended with buildingInProgress=true and no completion/failure event followed.
|
// Emitted during replay when a session ended with buildingInProgress=true and no completion/failure event followed.
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ContextBuildingInterrupted")
|
||||||
data class ContextBuildingInterruptedEvent(
|
data class ContextBuildingInterruptedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("CompressionApplied")
|
||||||
data class CompressionAppliedEvent(
|
data class CompressionAppliedEvent(
|
||||||
val contextPackId: ContextPackId,
|
val contextPackId: ContextPackId,
|
||||||
val layer: String,
|
val layer: String,
|
||||||
@@ -42,6 +48,7 @@ data class CompressionAppliedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ContextPackBuilt")
|
||||||
data class ContextPackBuiltEvent(
|
data class ContextPackBuiltEvent(
|
||||||
val contextPackId: ContextPackId,
|
val contextPackId: ContextPackId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -51,6 +58,7 @@ data class ContextPackBuiltEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SteeringNoteAdded")
|
||||||
data class SteeringNoteAddedEvent(
|
data class SteeringNoteAddedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val content: String,
|
val content: String,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import com.correx.core.events.types.ProviderId
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.inference.TokenUsage
|
import com.correx.core.inference.TokenUsage
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("InferenceStarted")
|
||||||
data class InferenceStartedEvent(
|
data class InferenceStartedEvent(
|
||||||
val requestId: InferenceRequestId,
|
val requestId: InferenceRequestId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -19,6 +21,7 @@ data class InferenceStartedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("InferenceCompleted")
|
||||||
data class InferenceCompletedEvent(
|
data class InferenceCompletedEvent(
|
||||||
val requestId: InferenceRequestId,
|
val requestId: InferenceRequestId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -30,6 +33,7 @@ data class InferenceCompletedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("InferenceFailed")
|
||||||
data class InferenceFailedEvent(
|
data class InferenceFailedEvent(
|
||||||
val requestId: InferenceRequestId,
|
val requestId: InferenceRequestId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -39,6 +43,7 @@ data class InferenceFailedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("InferenceTimeout")
|
||||||
data class InferenceTimeoutEvent(
|
data class InferenceTimeoutEvent(
|
||||||
val requestId: InferenceRequestId,
|
val requestId: InferenceRequestId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -48,6 +53,7 @@ data class InferenceTimeoutEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ModelLoaded")
|
||||||
data class ModelLoadedEvent(
|
data class ModelLoadedEvent(
|
||||||
val modelId: String,
|
val modelId: String,
|
||||||
val providerId: ProviderId,
|
val providerId: ProviderId,
|
||||||
@@ -55,6 +61,7 @@ data class ModelLoadedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ModelUnloaded")
|
||||||
data class ModelUnloadedEvent(
|
data class ModelUnloadedEvent(
|
||||||
val modelId: String,
|
val modelId: String,
|
||||||
val providerId: ProviderId,
|
val providerId: ProviderId,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.correx.core.events.events
|
|||||||
import com.correx.core.events.execution.RetryPolicy
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("WorkflowStarted")
|
||||||
data class WorkflowStartedEvent(
|
data class WorkflowStartedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val workflowId: String,
|
val workflowId: String,
|
||||||
@@ -14,6 +16,7 @@ data class WorkflowStartedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("WorkflowCompleted")
|
||||||
data class WorkflowCompletedEvent(
|
data class WorkflowCompletedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val terminalStageId: StageId,
|
val terminalStageId: StageId,
|
||||||
@@ -21,6 +24,7 @@ data class WorkflowCompletedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("WorkflowFailed")
|
||||||
data class WorkflowFailedEvent(
|
data class WorkflowFailedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -29,6 +33,7 @@ data class WorkflowFailedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("OrchestrationPaused")
|
||||||
data class OrchestrationPausedEvent(
|
data class OrchestrationPausedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -36,12 +41,14 @@ data class OrchestrationPausedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("OrchestrationResumed")
|
||||||
data class OrchestrationResumedEvent(
|
data class OrchestrationResumedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("RetryAttempted")
|
||||||
data class RetryAttemptedEvent(
|
data class RetryAttemptedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import com.correx.core.events.risk.RiskLevel
|
|||||||
import com.correx.core.events.types.RiskSummaryId
|
import com.correx.core.events.types.RiskSummaryId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("RiskAssessed")
|
||||||
data class RiskAssessedEvent(
|
data class RiskAssessedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
package com.correx.core.events.events
|
package com.correx.core.events.events
|
||||||
|
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SessionStarted")
|
||||||
data class SessionStartedEvent(
|
data class SessionStartedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val initialContextId: String? = null
|
val initialContextId: String? = null
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SessionPaused")
|
||||||
data class SessionPausedEvent(
|
data class SessionPausedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val reason: String? = null
|
val reason: String? = null
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SessionResumed")
|
||||||
data class SessionResumedEvent(
|
data class SessionResumedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SessionCompleted")
|
||||||
data class SessionCompletedEvent(
|
data class SessionCompletedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val summary: String? = null
|
val summary: String? = null
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("SessionFailed")
|
||||||
data class SessionFailedEvent(
|
data class SessionFailedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val errorCode: String? = null,
|
val errorCode: String? = null,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package com.correx.core.events.events
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.TransitionId
|
import com.correx.core.events.types.TransitionId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("StageStarted")
|
||||||
data class StageStartedEvent(
|
data class StageStartedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -13,6 +15,7 @@ data class StageStartedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("StageCompleted")
|
||||||
data class StageCompletedEvent(
|
data class StageCompletedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -20,6 +23,7 @@ data class StageCompletedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("StageFailed")
|
||||||
data class StageFailedEvent(
|
data class StageFailedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val stageId: StageId,
|
val stageId: StageId,
|
||||||
@@ -28,6 +32,7 @@ data class StageFailedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("TransitionExecuted")
|
||||||
data class TransitionExecutedEvent(
|
data class TransitionExecutedEvent(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val from: StageId,
|
val from: StageId,
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import com.correx.core.approvals.Tier
|
|||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolExecutionCompleted")
|
||||||
data class ToolExecutionCompletedEvent(
|
data class ToolExecutionCompletedEvent(
|
||||||
val invocationId: ToolInvocationId,
|
val invocationId: ToolInvocationId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -15,6 +17,7 @@ data class ToolExecutionCompletedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolExecutionFailed")
|
||||||
data class ToolExecutionFailedEvent(
|
data class ToolExecutionFailedEvent(
|
||||||
val invocationId: ToolInvocationId,
|
val invocationId: ToolInvocationId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -23,6 +26,7 @@ data class ToolExecutionFailedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolExecutionRejected")
|
||||||
data class ToolExecutionRejectedEvent(
|
data class ToolExecutionRejectedEvent(
|
||||||
val invocationId: ToolInvocationId,
|
val invocationId: ToolInvocationId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -32,6 +36,7 @@ data class ToolExecutionRejectedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolExecutionStarted")
|
||||||
data class ToolExecutionStartedEvent(
|
data class ToolExecutionStartedEvent(
|
||||||
val invocationId: ToolInvocationId,
|
val invocationId: ToolInvocationId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -39,6 +44,7 @@ data class ToolExecutionStartedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolInvocationRequested")
|
||||||
data class ToolInvocationRequestedEvent(
|
data class ToolInvocationRequestedEvent(
|
||||||
val invocationId: ToolInvocationId,
|
val invocationId: ToolInvocationId,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
@@ -49,6 +55,7 @@ data class ToolInvocationRequestedEvent(
|
|||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
@SerialName("ToolInvoked")
|
||||||
data class ToolInvokedEvent(
|
data class ToolInvokedEvent(
|
||||||
val toolId: String,
|
val toolId: String,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
package com.correx.core.events.risk
|
package com.correx.core.events.risk
|
||||||
|
|
||||||
enum class RiskAction { PROCEED, PROMPT_USER, BLOCK }
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RiskAction {
|
||||||
|
@SerialName("PROCEED") PROCEED,
|
||||||
|
@SerialName("PROMPT_USER") PROMPT_USER,
|
||||||
|
@SerialName("BLOCK") BLOCK,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
package com.correx.core.events.risk
|
package com.correx.core.events.risk
|
||||||
|
|
||||||
enum class RiskLevel { LOW, MEDIUM, HIGH, CRITICAL }
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RiskLevel {
|
||||||
|
@SerialName("LOW") LOW,
|
||||||
|
@SerialName("MEDIUM") MEDIUM,
|
||||||
|
@SerialName("HIGH") HIGH,
|
||||||
|
@SerialName("CRITICAL") CRITICAL,
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,4 +106,6 @@ val eventModule = SerializersModule {
|
|||||||
|
|
||||||
val eventJson = Json {
|
val eventJson = Json {
|
||||||
serializersModule = eventModule
|
serializersModule = eventModule
|
||||||
|
encodeDefaults = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-7
@@ -1,14 +1,15 @@
|
|||||||
package com.correx.core.events.types
|
package com.correx.core.events.types
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ArtifactRelationshipType {
|
enum class ArtifactRelationshipType {
|
||||||
PARENT,
|
@SerialName("PARENT") PARENT,
|
||||||
CHILD,
|
@SerialName("CHILD") CHILD,
|
||||||
SUPERSEDES,
|
@SerialName("SUPERSEDES") SUPERSEDES,
|
||||||
DERIVED_FROM,
|
@SerialName("DERIVED_FROM") DERIVED_FROM,
|
||||||
VALIDATED_BY,
|
@SerialName("VALIDATED_BY") VALIDATED_BY,
|
||||||
APPROVED_BY,
|
@SerialName("APPROVED_BY") APPROVED_BY,
|
||||||
GENERATED_FROM
|
@SerialName("GENERATED_FROM") GENERATED_FROM,
|
||||||
}
|
}
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.correx.core.events
|
||||||
|
|
||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.GrantScope
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
|
import com.correx.core.events.events.ContextPackBuiltEvent
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||||
|
import com.correx.core.events.events.RiskAssessedEvent
|
||||||
|
import com.correx.core.events.events.SessionStartedEvent
|
||||||
|
import com.correx.core.events.events.ToolExecutionFailedEvent
|
||||||
|
import com.correx.core.events.risk.RiskAction
|
||||||
|
import com.correx.core.events.risk.RiskLevel
|
||||||
|
import com.correx.core.events.serialization.eventJson
|
||||||
|
import com.correx.core.events.types.ApprovalDecisionId
|
||||||
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
|
import com.correx.core.events.types.ContextPackId
|
||||||
|
import com.correx.core.events.types.RiskSummaryId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventSerializationHardeningTest {
|
||||||
|
|
||||||
|
private val sessionId = SessionId("s-1")
|
||||||
|
private val stageId = StageId("st-1")
|
||||||
|
private val ts = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
private val payloads: List<Pair<String, EventPayload>> = listOf(
|
||||||
|
"SessionStarted" to SessionStartedEvent(sessionId = sessionId),
|
||||||
|
"ToolExecutionFailed" to ToolExecutionFailedEvent(
|
||||||
|
invocationId = ToolInvocationId("inv-1"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
toolName = "echo",
|
||||||
|
reason = "boom"
|
||||||
|
),
|
||||||
|
"ApprovalDecisionResolved" to ApprovalDecisionResolvedEvent(
|
||||||
|
decisionId = ApprovalDecisionId("d-1"),
|
||||||
|
requestId = ApprovalRequestId("r-1"),
|
||||||
|
outcome = ApprovalOutcome.APPROVED,
|
||||||
|
status = ApprovalStatus.COMPLETED,
|
||||||
|
tier = Tier.T2,
|
||||||
|
resolutionTimestamp = ts,
|
||||||
|
reason = null
|
||||||
|
),
|
||||||
|
"ContextPackBuilt" to ContextPackBuiltEvent(
|
||||||
|
contextPackId = ContextPackId("cp-1"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
budgetUsed = 100,
|
||||||
|
budgetLimit = 4096
|
||||||
|
),
|
||||||
|
"OrchestrationPaused" to OrchestrationPausedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
reason = "APPROVAL_PENDING"
|
||||||
|
),
|
||||||
|
"RiskAssessed" to RiskAssessedEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
riskSummaryId = RiskSummaryId("rs-1"),
|
||||||
|
level = RiskLevel.MEDIUM,
|
||||||
|
action = RiskAction.PROCEED
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `discriminator field equals pinned SerialName for all representative events`() {
|
||||||
|
for ((expectedType, payload) in payloads) {
|
||||||
|
val json = eventJson.encodeToString(EventPayload.serializer(), payload)
|
||||||
|
val element = Json.parseToJsonElement(json)
|
||||||
|
val actualType = element.jsonObject["type"]?.toString()?.removeSurrounding("\"")
|
||||||
|
assertEquals(
|
||||||
|
expectedType,
|
||||||
|
actualType,
|
||||||
|
"Expected discriminator 'type'=\"$expectedType\" for ${payload::class.simpleName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all representative events round-trip through EventPayload polymorphic serializer`() {
|
||||||
|
for ((_, payload) in payloads) {
|
||||||
|
val json = eventJson.encodeToString(EventPayload.serializer(), payload)
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||||
|
assertEquals(payload, decoded, "Round-trip failed for ${payload::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown fields in JSON are tolerated (ignoreUnknownKeys=true)`() {
|
||||||
|
val event = SessionStartedEvent(sessionId = sessionId)
|
||||||
|
val json = eventJson.encodeToString(EventPayload.serializer(), event)
|
||||||
|
val withExtra = json.replace("{", "{\"bogusField\":123,")
|
||||||
|
assertDoesNotThrow {
|
||||||
|
eventJson.decodeFromString(EventPayload.serializer(), withExtra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ dependencies {
|
|||||||
implementation(project(":core:artifacts-store"))
|
implementation(project(":core:artifacts-store"))
|
||||||
implementation "org.xerial:sqlite-jdbc"
|
implementation "org.xerial:sqlite-jdbc"
|
||||||
testImplementation(testFixtures(project(":testing:contracts")))
|
testImplementation(testFixtures(project(":testing:contracts")))
|
||||||
|
testImplementation(project(":testing:fixtures"))
|
||||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-31
@@ -17,6 +17,8 @@ import kotlinx.coroutines.channels.BufferOverflow
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import java.sql.Connection
|
import java.sql.Connection
|
||||||
@@ -28,6 +30,7 @@ class SqliteEventStore(
|
|||||||
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson),
|
private val jsonSerializer: JsonEventSerializer = JsonEventSerializer(eventJson),
|
||||||
private val artifactStore: ArtifactStore,
|
private val artifactStore: ArtifactStore,
|
||||||
) : EventStore {
|
) : EventStore {
|
||||||
|
private val appendMutex = Mutex()
|
||||||
private val subscriptions = ConcurrentHashMap<SessionId, MutableSharedFlow<StoredEvent>>()
|
private val subscriptions = ConcurrentHashMap<SessionId, MutableSharedFlow<StoredEvent>>()
|
||||||
private val globalFlow = MutableSharedFlow<StoredEvent>(
|
private val globalFlow = MutableSharedFlow<StoredEvent>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
@@ -37,6 +40,9 @@ class SqliteEventStore(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
connection.createStatement().use { stmt ->
|
connection.createStatement().use { stmt ->
|
||||||
|
stmt.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
stmt.execute("PRAGMA busy_timeout=5000;")
|
||||||
|
stmt.execute("PRAGMA synchronous=NORMAL;")
|
||||||
stmt.execute(
|
stmt.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
@@ -63,21 +69,23 @@ class SqliteEventStore(
|
|||||||
|
|
||||||
override suspend fun append(event: NewEvent): StoredEvent {
|
override suspend fun append(event: NewEvent): StoredEvent {
|
||||||
var stored: StoredEvent? = null
|
var stored: StoredEvent? = null
|
||||||
artifactStore.flushBefore {
|
appendMutex.withLock {
|
||||||
withContext(Dispatchers.IO) {
|
artifactStore.flushBefore {
|
||||||
stored = connection.transaction {
|
withContext(Dispatchers.IO) {
|
||||||
val existing = findByEventId(event.metadata.eventId)
|
stored = connection.transaction {
|
||||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
val existing = findByEventId(event.metadata.eventId)
|
||||||
val globalSeqVal = nextGlobalSequence()
|
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||||
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId)
|
val globalSeqVal = nextGlobalSequence()
|
||||||
val s = StoredEvent(
|
val sessionSeqVal = nextSessionSequence(event.metadata.sessionId)
|
||||||
metadata = event.metadata,
|
val s = StoredEvent(
|
||||||
sequence = globalSeqVal,
|
metadata = event.metadata,
|
||||||
sessionSequence = sessionSeqVal,
|
sequence = globalSeqVal,
|
||||||
payload = event.payload,
|
sessionSequence = sessionSeqVal,
|
||||||
)
|
payload = event.payload,
|
||||||
insert(s)
|
)
|
||||||
s
|
insert(s)
|
||||||
|
s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,22 +99,24 @@ class SqliteEventStore(
|
|||||||
if (events.isEmpty()) return emptyList()
|
if (events.isEmpty()) return emptyList()
|
||||||
val sessionId = events.first().metadata.sessionId
|
val sessionId = events.first().metadata.sessionId
|
||||||
var stored: List<StoredEvent> = emptyList()
|
var stored: List<StoredEvent> = emptyList()
|
||||||
artifactStore.flushBefore {
|
appendMutex.withLock {
|
||||||
withContext(Dispatchers.IO) {
|
artifactStore.flushBefore {
|
||||||
stored = connection.transaction {
|
withContext(Dispatchers.IO) {
|
||||||
events.map { event ->
|
stored = connection.transaction {
|
||||||
val existing = findByEventId(event.metadata.eventId)
|
events.map { event ->
|
||||||
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
val existing = findByEventId(event.metadata.eventId)
|
||||||
val globalSeqVal = nextGlobalSequence()
|
check(existing == null) { "duplicate event_id: ${event.metadata.eventId}" }
|
||||||
val sessionSeqVal = nextSessionSequence(sessionId)
|
val globalSeqVal = nextGlobalSequence()
|
||||||
val s = StoredEvent(
|
val sessionSeqVal = nextSessionSequence(sessionId)
|
||||||
metadata = event.metadata,
|
val s = StoredEvent(
|
||||||
sequence = globalSeqVal,
|
metadata = event.metadata,
|
||||||
sessionSequence = sessionSeqVal,
|
sequence = globalSeqVal,
|
||||||
payload = event.payload,
|
sessionSequence = sessionSeqVal,
|
||||||
)
|
payload = event.payload,
|
||||||
insert(s)
|
)
|
||||||
s
|
insert(s)
|
||||||
|
s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
package com.correx.infrastructure.persistence.util
|
package com.correx.infrastructure.persistence.util
|
||||||
|
|
||||||
import java.sql.Connection
|
import java.sql.Connection
|
||||||
import java.sql.SQLException
|
|
||||||
|
|
||||||
object JDBCHelper {
|
object JDBCHelper {
|
||||||
inline fun <T> Connection.transaction(block: () -> T): T {
|
inline fun <T> Connection.transaction(block: () -> T): T {
|
||||||
@@ -13,7 +12,7 @@ object JDBCHelper {
|
|||||||
|
|
||||||
commit()
|
commit()
|
||||||
return result
|
return result
|
||||||
} catch (e: SQLException) {
|
} catch (e: Throwable) {
|
||||||
rollback()
|
rollback()
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.correx.infrastructure.persistence
|
||||||
|
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
||||||
|
import com.correx.testing.fixtures.EventFixtures
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.sql.DriverManager
|
||||||
|
|
||||||
|
class SqliteEventStoreConcurrencyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `concurrent appends to same session produce no dropped or duplicate sequence numbers`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
): Unit = runBlocking {
|
||||||
|
val dbFile = tempDir.resolve("events.db").toAbsolutePath().toString()
|
||||||
|
val conn = DriverManager.getConnection("jdbc:sqlite:$dbFile")
|
||||||
|
val store = SqliteEventStore(conn, artifactStore = NoopArtifactStore())
|
||||||
|
|
||||||
|
val sessionId = SessionId("concurrent-session")
|
||||||
|
val n = 50
|
||||||
|
|
||||||
|
(1..n)
|
||||||
|
.map { i ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
store.append(EventFixtures.newEvent(EventId("e-$i"), sessionId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
|
||||||
|
val events = store.read(sessionId)
|
||||||
|
|
||||||
|
assertEquals(n, events.size, "Expected exactly $n events but got ${events.size}")
|
||||||
|
|
||||||
|
val sessionSeqs = events.map { it.sessionSequence }.sorted()
|
||||||
|
assertEquals((1L..n.toLong()).toList(), sessionSeqs, "Session sequences must be exactly 1..$n with no gaps or duplicates")
|
||||||
|
|
||||||
|
val globalSeqs = events.map { it.sequence }
|
||||||
|
assertEquals(globalSeqs.size, globalSeqs.toSet().size, "Global sequence values must all be unique")
|
||||||
|
|
||||||
|
globalSeqs.forEach { seq ->
|
||||||
|
assertTrue(seq >= 1L, "Global sequence $seq must be >= 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class ApprovalEngineEdgeCasesTest {
|
|||||||
fun `grants from different scopes apply only to matching identity`() {
|
fun `grants from different scopes apply only to matching identity`() {
|
||||||
val sessionGrant = ApprovalGrant(
|
val sessionGrant = ApprovalGrant(
|
||||||
id = GrantId("g1"),
|
id = GrantId("g1"),
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T3),
|
permittedTiers = setOf(Tier.T3),
|
||||||
reason = "",
|
reason = "",
|
||||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
@@ -59,7 +59,7 @@ class ApprovalEngineEdgeCasesTest {
|
|||||||
val past = Instant.parse("2025-12-31T23:59:59Z")
|
val past = Instant.parse("2025-12-31T23:59:59Z")
|
||||||
val expiredGrant = ApprovalGrant(
|
val expiredGrant = ApprovalGrant(
|
||||||
id = GrantId("g1"),
|
id = GrantId("g1"),
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T2),
|
permittedTiers = setOf(Tier.T2),
|
||||||
reason = "",
|
reason = "",
|
||||||
timestamp = past,
|
timestamp = past,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.GrantScope
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.approvals.model.ApprovalContext
|
||||||
|
import com.correx.core.approvals.model.ApprovalGrant
|
||||||
|
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||||
|
import com.correx.core.events.types.GrantId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.sessions.ApprovalMode
|
||||||
|
import com.correx.testing.fixtures.request
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class GrantSecurityTest {
|
||||||
|
|
||||||
|
private val engine = DefaultApprovalEngine()
|
||||||
|
|
||||||
|
private val now = Instant.parse("2026-01-01T00:00:01Z")
|
||||||
|
private val grantTime = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
private fun denyCtx() = ApprovalContext(
|
||||||
|
ApprovalScopeIdentity(SessionId("s1"), null, null),
|
||||||
|
ApprovalMode.DENY
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── 1. Operation isolation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SESSION grant for shell does not auto-approve write_file request`() {
|
||||||
|
val req = request("r1", Tier.T2).copy(toolName = "write_file")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||||
|
assertNull(decision.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SESSION grant for write_file does not auto-approve shell request`() {
|
||||||
|
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "write_file"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||||
|
assertNull(decision.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Tier ceiling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grant with max tier T2 does not auto-approve T3 request even with matching toolName`() {
|
||||||
|
val req = request("r1", Tier.T3).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||||
|
assertNull(decision.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grant with max tier T2 auto-approves T2 request with matching toolName (positive control)`() {
|
||||||
|
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||||
|
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grant with max tier T2 auto-approves T1 request with matching toolName (ceiling covers lower tiers)`() {
|
||||||
|
val req = request("r1", Tier.T1).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||||
|
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grant with max tier T2 auto-approves T0 request with matching toolName (ceiling covers lowest tier)`() {
|
||||||
|
val req = request("r1", Tier.T0).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalOutcome.AUTO_APPROVED, decision.outcome)
|
||||||
|
assertEquals(ApprovalStatus.COMPLETED, decision.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. No blanket grant ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SESSION grant with null toolName does not match any request (default-deny preserved)`() {
|
||||||
|
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = null),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||||
|
assertNull(decision.outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SESSION grant with null toolName does not match request with null toolName`() {
|
||||||
|
val req = request("r1", Tier.T2).copy(toolName = null)
|
||||||
|
val grant = ApprovalGrant(
|
||||||
|
id = GrantId("g1"),
|
||||||
|
scope = GrantScope.SESSION(toolName = null),
|
||||||
|
permittedTiers = setOf(Tier.T2),
|
||||||
|
reason = "test",
|
||||||
|
timestamp = grantTime
|
||||||
|
)
|
||||||
|
|
||||||
|
val decision = engine.evaluate(req, denyCtx(), listOf(grant), now)
|
||||||
|
|
||||||
|
assertEquals(ApprovalStatus.PENDING, decision.state)
|
||||||
|
assertNull(decision.outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,10 @@ class GrantSemanticsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `grant permits exact tier - auto_approved`() {
|
fun `grant permits exact tier - auto_approved`() {
|
||||||
val req = request("r1", Tier.T2)
|
val req = request("r1", Tier.T2).copy(toolName = "shell")
|
||||||
val grant = ApprovalGrant(
|
val grant = ApprovalGrant(
|
||||||
id = GrantId("g1"),
|
id = GrantId("g1"),
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(toolName = "shell"),
|
||||||
permittedTiers = setOf(Tier.T2),
|
permittedTiers = setOf(Tier.T2),
|
||||||
reason = "test",
|
reason = "test",
|
||||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
@@ -45,7 +45,7 @@ class GrantSemanticsTest {
|
|||||||
val req = request("r1", Tier.T3)
|
val req = request("r1", Tier.T3)
|
||||||
val grant = ApprovalGrant(
|
val grant = ApprovalGrant(
|
||||||
id = GrantId("g1"),
|
id = GrantId("g1"),
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(Tier.T2),
|
permittedTiers = setOf(Tier.T2),
|
||||||
reason = "test",
|
reason = "test",
|
||||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
@@ -63,18 +63,18 @@ class GrantSemanticsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `multiple grants - any match is enough`() {
|
fun `multiple grants - any match is enough`() {
|
||||||
val req = request("r1", Tier.T4)
|
val req = request("r1", Tier.T4).copy(toolName = "write_file")
|
||||||
val grants = listOf(
|
val grants = listOf(
|
||||||
ApprovalGrant(
|
ApprovalGrant(
|
||||||
GrantId("g1"),
|
GrantId("g1"),
|
||||||
GrantScope.SESSION,
|
GrantScope.SESSION(toolName = "shell"),
|
||||||
setOf(Tier.T3),
|
setOf(Tier.T3),
|
||||||
"",
|
"",
|
||||||
Instant.parse("2026-01-01T00:00:00Z")
|
Instant.parse("2026-01-01T00:00:00Z")
|
||||||
),
|
),
|
||||||
ApprovalGrant(
|
ApprovalGrant(
|
||||||
GrantId("g2"),
|
GrantId("g2"),
|
||||||
GrantScope.SESSION,
|
GrantScope.SESSION(toolName = "write_file"),
|
||||||
setOf(Tier.T4),
|
setOf(Tier.T4),
|
||||||
"",
|
"",
|
||||||
Instant.parse("2026-01-01T00:00:00Z")
|
Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class NoExecutionCouplingTest {
|
|||||||
val grants = mutableListOf(
|
val grants = mutableListOf(
|
||||||
ApprovalGrant(
|
ApprovalGrant(
|
||||||
GrantId("g1"),
|
GrantId("g1"),
|
||||||
GrantScope.SESSION,
|
GrantScope.SESSION(),
|
||||||
setOf(Tier.T2),
|
setOf(Tier.T2),
|
||||||
"",
|
"",
|
||||||
Instant.parse("2026-01-01T00:00:00Z")
|
Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class TierImmutabilityTest {
|
|||||||
val req = request("r1", tier)
|
val req = request("r1", tier)
|
||||||
val grant = ApprovalGrant(
|
val grant = ApprovalGrant(
|
||||||
id = GrantId("g1"),
|
id = GrantId("g1"),
|
||||||
scope = GrantScope.SESSION,
|
scope = GrantScope.SESSION(),
|
||||||
permittedTiers = setOf(tier),
|
permittedTiers = setOf(tier),
|
||||||
reason = "test",
|
reason = "test",
|
||||||
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ApprovalReplayTest {
|
|||||||
val grants = listOf(
|
val grants = listOf(
|
||||||
ApprovalGrant(
|
ApprovalGrant(
|
||||||
GrantId("g1"),
|
GrantId("g1"),
|
||||||
GrantScope.SESSION,
|
GrantScope.SESSION(),
|
||||||
setOf(Tier.T2),
|
setOf(Tier.T2),
|
||||||
"reason",
|
"reason",
|
||||||
Instant.parse("2026-01-01T00:00:00Z")
|
Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|||||||
Reference in New Issue
Block a user