refactor(approval): wire ApprovalCoordinator end-to-end

ApprovalCoordinator (added in epic-13) was never instantiated. Its four
capabilities — multi-client fan-out, single-resolver dedupe, request
timeout, and a unified domain mapper — were duplicated piecemeal across
GlobalStreamHandler and SessionStreamHandler, with a STEER-outcome
divergence between them (coordinator mapped STEER → APPROVED while
GlobalStreamHandler mapped STEER → REJECTED). This refactor wires the
coordinator and removes the duplication.

Protocol: drop ApprovalDecision.STEER. STEER is no longer a discriminator;
it's expressed as (APPROVE | REJECT) + non-null steeringNote, so the user
can steer alongside either choice. TUI's current single-keybind STEER
input flow now sends APPROVE + note (matching the original epic-13
intent); a future overlay UX will let the user pick APPROVE or REJECT
explicitly.

Mapper: ClientMessage.ApprovalResponse → DomainApprovalDecision lives in
a single shared `toDomain` helper (apps/server/approval/
ApprovalResponseMapper.kt). Both stream handlers and the coordinator
use it.

Config: delete the duplicate apps/server/approval/ApprovalConfig.kt;
ApprovalCoordinator now takes core.config.ApprovalConfig (already had
timeoutMs=300_000L). ConfigLoader already populates it.

Wiring:
- ServerModule owns a SupervisorJob-backed moduleScope, constructs
  ApprovalCoordinator, and on start() subscribes eventStore.subscribeAll()
  to route ApprovalRequestedEvent payloads to onApprovalRequested.
- GlobalStreamHandler delegates ApprovalResponse to
  approvalCoordinator.handleResponse and registers/unregisters via the
  new registerGlobalClient/unregisterGlobalClient (global sockets aren't
  bound to one SessionId, so broadcasts union session and global sets).
- SessionStreamHandler delegates ApprovalResponse and registers per-session
  via registerClient/unregisterClient.
- Timeout is on by default at 5 minutes via config.timeoutMs.

Tests: ApprovalCoordinatorWiringTest covers delegate path, dedupe
producing ProtocolError, and event-stream subscription routing.
This commit is contained in:
2026-05-25 19:02:51 +04:00
parent 09f47bf8e3
commit 5abf7d1253
12 changed files with 316 additions and 143 deletions
@@ -69,7 +69,7 @@ object InputReducer {
Effect.SendWs(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(active.requestId),
decision = ApprovalDecision.STEER,
decision = ApprovalDecision.APPROVE,
steeringNote = text,
),
),
@@ -132,7 +132,7 @@ class InputReducerTest {
}
@Test
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse STEER`() {
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse APPROVE with steering note`() {
val (state, effects) = reduce(
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
action = Action.SubmitInput,
@@ -141,7 +141,7 @@ class InputReducerTest {
assertEquals(1, effects.size)
val effect = effects[0] as Effect.SendWs
val msg = effect.message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.STEER, msg.decision)
assertEquals(ApprovalDecision.APPROVE, msg.decision)
assertEquals("steer this way", msg.steeringNote)
assertEquals("req-1", msg.requestId.value)
}
@@ -155,7 +155,7 @@ class InputReducerTest {
assertEquals(null, state.sessions.sessions[0].pendingApproval)
assertEquals(1, effects.size)
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.STEER, msg.decision)
assertEquals(ApprovalDecision.APPROVE, msg.decision)
assertEquals("go left", msg.steeringNote)
assertEquals("req-1", msg.requestId.value)
}
@@ -46,14 +46,14 @@ class ApprovalResponseGlobalSocketRoundTripTest {
fun `ApprovalResponse with steering note round-trips`() {
val original: ClientMessage = ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId("req-steer"),
decision = ApprovalDecision.STEER,
decision = ApprovalDecision.APPROVE,
steeringNote = "please retry with smaller batch",
)
val wire = clientJson.encodeToString(original)
val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse
assertEquals(ApprovalDecision.STEER, decoded.decision)
assertEquals(ApprovalDecision.APPROVE, decoded.decision)
assertEquals("please retry with smaller batch", decoded.steeringNote)
}
}