Introduce clientTransport interface in duoworkflow runner

What does this MR do and why?

This is a refactor of workhorse/internal/ai_assist/duoworkflow/. There is no behavior change.

runner orchestrates one Duo Agent Platform workflow execution: it holds the distributed lock, drives the stop handshake with the Duo Workflow Service (DWS), handles graceful shutdown, executes MCP tool calls, and records metrics. Until now it also assumed its client was a WebSocket: it held a concrete *wsManager in a field named ws, and its method names and log messages were written in WebSocket terms.

This MR adds a clientTransport interface in a new transport.go and makes runner depend on it. wsManager becomes the WebSocket implementation.

Changes:

  • New clientTransport interface: Start, KeepaliveInterval, Keepalive, ReadClientEvent, ReadError, WriteAction, SendGoingAway, SendInvalidRequest, Close.
  • runner.ws *wsManager becomes runner.client clientTransport. newRunner takes a clientTransport; handler.go passes newWsManager(conn).
  • Two WebSocket-specific steps moved from runner into the new wsManager.Start(): registering the gorilla/websocket pong handler and arming the initial read deadline. runner.Execute calls Start() once before spawning goroutines.
  • wsManager.Ping() is renamed Keepalive(). The interval is exposed through KeepaliveInterval() instead of the runner reading the wsPingInterval constant directly.
  • Renames inside runner, so shared orchestration no longer says "WebSocket": handleWebSocketMessages -> handleClientEvents, handleWebSocketMessage -> handleClientEvent, pingWebSocket -> keepaliveClient. Comments and log messages follow.
  • The StopWorkflowRequest reason sent to DWS on keepalive failure is unchanged. It is still the string WORKHORSE_WEBSOCKET_PING_FAILED, now behind a named constant reasonKeepaliveFailed, so DWS-side telemetry that matches on the value keeps working.
  • Tests updated mechanically. The runner test that exercised the pong handler moved to TestWsManager_Start in websocket_test.go, because that behavior now lives on the transport.

One ordering nuance: the initial read deadline is now set synchronously in Start(), before the read goroutine starts. Previously the ping goroutine set it. This closes a small window in which the reader could run with no deadline.

Why

This is step 1 of adding a second transport. A follow-up MR will add a plain-HTTP transport, so a server-side caller (for example Rails delivering a Slack chat turn) can start a flow with a single request and read the resulting actions as a newline-delimited JSON stream, instead of needing a CI job just to hold a connection open. Sharing runner means that transport inherits the locking, stop handshake, shutdown handling, MCP tool execution, and metrics that the WebSocket path already has, rather than forking the logic.

How to set up and validate locally

There is nothing to click through, since behavior is unchanged. Run the package tests:

cd workhorse
go test ./internal/ai_assist/duoworkflow/ -count=1
go test -race ./internal/ai_assist/duoworkflow/ -count=1
make golangci

A couple of the lock tests need a workhorse/config.toml with Redis configured. GDK provides one.

MR acceptance checklist

Edited by Igor Drozdov

Merge request reports

Loading
Loading