Files
correx/apps/tui-go/internal/protocol/protocol.go
T
kami 15248cae8a feat(recovery,context): tier-2 intent-holder arbiter + remaining-delta pinning + surfaced signal events
- recovery: two-tier repair ladder — owner then arbiter (recovery stage recast
  as intent-holder, holds initial intent, reconciles cross-file contract disputes)
  with independent INTENT_ROUTE_BUDGET; RecoveryRoutingTest coverage
- context: remaining-delta checklist pinning (RemainingDelta{Pinning,Entry}Test)
- surface write-only events (session name, quality signals) into stats/browse
- parseToolArguments lenient-Json fallback for bare-key drift
- tools: ChildProcess seam
2026-07-11 23:56:52 +04:00

618 lines
24 KiB
Go

// Package protocol implements the correx WebSocket wire contract used to talk
// to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a
// "type" class discriminator. Server->client variants carry @SerialName values
// (e.g. "session.announced"); client->server variants have no @SerialName, so the
// discriminator is the fully-qualified Kotlin class name.
package protocol
import "encoding/json"
// clientPrefix is the kotlinx default serial name for nested ClientMessage
// subclasses (qualified class name). The server decodes against this exact value.
const clientPrefix = "com.correx.apps.server.protocol.ClientMessage."
// Server message discriminator values (@SerialName on ServerMessage variants).
const (
TypeSessionAnnounced = "session.announced"
TypeChatTurn = "chat.turn"
TypeSessionPaused = "session.paused"
TypeSessionResumed = "session.resumed"
TypeSessionCompleted = "session.completed"
TypeSessionFailed = "session.failed"
TypeSessionSnapshot = "session_snapshot"
TypeStageStarted = "stage.started"
TypeStageCompleted = "stage.completed"
TypeStageFailed = "stage.failed"
TypeInferenceStarted = "inference.started"
TypeInferenceDone = "inference.completed"
TypeInferenceTimeout = "inference.timed_out"
TypeInferenceFailed = "inference.failed"
TypeInferenceRetry = "inference.retry"
TypeToolStarted = "tool.started"
TypeToolCompleted = "tool.completed"
TypeToolFailed = "tool.failed"
TypeToolRejected = "tool.rejected"
TypeApprovalRequired = "approval.required"
TypeApprovalResolved = "approval.resolved"
TypeClarification = "clarification.required"
TypeWorkflowProposed = "workflow.proposed"
TypeSessionRenamed = "session.renamed"
TypeWorkspaceBound = "session.workspace_bound"
TypeStageManifest = "stage.tool_manifest"
TypeProviderStatus = "provider.status_changed"
TypeProtocolError = "protocol_error"
TypeArtifactCreated = "artifact.created"
TypeArtifactValid = "artifact.validated"
TypePlanLocked = "plan.locked"
TypeRouterResponse = "router.response"
TypeSnapshotComplete = "snapshot_complete"
TypeWorkflowList = "workflow.list"
TypeToolAssessed = "tool.assessed"
TypeModelChanged = "model.changed"
TypeModelList = "model.list"
TypeResourceStatus = "resource.status"
TypeRouterNarration = "router.narration"
TypeArtifactList = "artifact.list"
TypeConfigSnapshot = "config.snapshot"
TypeProjectProfile = "project_profile.snapshot"
TypeOperatorProfile = "operator_profile.snapshot"
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks"
TypeFileList = "file.list"
TypeGrantList = "grant.list"
TypeReviewFindings = "review.findings"
)
// ServerMessage is a flat decode of every server->client variant. Field names
// across variants don't collide in incompatible JSON types, so a single struct
// keyed by Type decodes the whole protocol. Value-class IDs serialize as plain
// strings (verified against ServerMessageSerializationTest).
type ServerMessage struct {
Type string `json:"type"`
SessionID string `json:"sessionId"`
WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"`
ToolName string `json:"toolName"`
Role string `json:"role"`
TurnID string `json:"turnId"`
Reason string `json:"reason"`
Summary string `json:"outputSummary"`
Response string `json:"responseText"`
Reasoning string `json:"reasoning"` // inference.completed: model thinking trace (collapsed view)
LastOutput string `json:"lastOutput"` // session_snapshot: last stage output (reopen)
LastResponse string `json:"lastResponse"` // session_snapshot: last stage response (reopen)
Content string `json:"content"`
Diff *string `json:"diff"`
// tool.completed: file/dir path(s) the tool read, wrote, or listed.
AffectedEntities []string `json:"affectedEntities"`
// tool.started: pretty "key=value" call arguments (path=…, command="…").
Params []string `json:"params"`
Tier string `json:"tier"`
Message string `json:"message"`
RequestID string `json:"requestId"`
ArtifactID string `json:"artifactId"`
ProviderID string `json:"providerId"`
Preview *string `json:"preview"`
Disposition string `json:"disposition"`
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd
Name string `json:"name"` // session.renamed: human title derived from intent
// plan.locked: the compiled phase-2 stage ids in execution order
StageIDs []string `json:"stageIds"`
// inference.retry
AttemptNumber int `json:"attemptNumber"`
MaxAttempts int `json:"maxAttempts"`
FailureReason string `json:"failureReason"`
SteeringEmitted bool `json:"steeringEmitted"`
OccurredAt int64 `json:"occurredAt"`
ElapsedMs int64 `json:"elapsedMs"`
LastSequence int64 `json:"lastSequence"`
// model.changed
ModelID string `json:"modelId"`
Loaded bool `json:"loaded"`
// model.list (current reuses the field below)
Models []string `json:"models"`
Current string `json:"current"`
// chat.turn router metrics (nil when not a ROUTER turn or server omits them)
LatencyMs *int64 `json:"latencyMs"`
TotalTokens *int `json:"totalTokens"`
// resource.status (nil = unavailable)
GpuMemoryUsedMb *int64 `json:"gpuMemoryUsedMb"`
GpuMemoryTotalMb *int64 `json:"gpuMemoryTotalMb"`
GpuUtilizationPct *int `json:"gpuUtilizationPct"`
ProcessRssMb *int64 `json:"processRssMb"`
SystemRamUsedMb *int64 `json:"systemRamUsedMb"`
SystemRamTotalMb *int64 `json:"systemRamTotalMb"`
State *SessionStateDto `json:"state"`
RiskSummary *RiskSummaryDto `json:"riskSummary"`
Status *ProviderHealthDto `json:"status"`
PendingAppr []ApprovalDto `json:"pendingApprovals"`
Tools []ToolRecordDto `json:"tools"`
RecentEvents []EventEntryDto `json:"recentEvents"`
Transcript []TranscriptRowDto `json:"transcript"`
Stages []StageToolDecl `json:"stages"`
Workflows []WorkflowDto `json:"workflows"`
AssessedIssues []AssessedIssueDto `json:"issues"`
Artifacts []ArtifactDto `json:"artifacts"`
// review.findings
ReviewVerdict string `json:"verdict"`
ReviewFindings []ReviewFindingDto `json:"findings"`
ReviewBlocked bool `json:"blocked"`
// config.snapshot
ConfigFields []ConfigFieldDto `json:"fields"`
ConfigRestartRequired []string `json:"restartRequired"`
ConfigError *string `json:"error"`
// project_profile.snapshot / operator_profile.snapshot (About/Conventions shared; Commands is
// project-only, ApprovalMode/PreferredModels/ProposedAdaptation are operator-only)
About string `json:"about"`
Conventions []string `json:"conventions"`
Commands map[string]string `json:"commands"`
ApprovalMode string `json:"approvalMode"`
PreferredModels []string `json:"preferredModels"`
ProposedAdaptation *string `json:"proposedAdaptation"`
// session.stats — derived metrics for a session (reply to GetSessionStats)
Stats *StatsDto `json:"stats"`
// health.checks — system health report (reply to GetHealthChecks)
Health *HealthDto `json:"health"`
// clarification.required — open questions a stage raised for the operator
Questions []ClarificationQuestionDto `json:"questions"`
// workflow.proposed — the router's triage suggestion of candidate workflows
ProposalID string `json:"proposalId"`
Prompt string `json:"prompt"`
OriginalRequest string `json:"originalRequest"`
Candidates []ProposedWorkflowDto `json:"candidates"`
// idea.list — the cross-session idea board
Ideas []IdeaDto `json:"ideas"`
// file.list — the session workspace's file paths (for the @ file-ref picker)
Paths []string `json:"paths"`
// grant.list — the active cross-session (PROJECT/GLOBAL) standing grants
Grants []GrantDto `json:"grants"`
}
// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto.
type GrantDto struct {
GrantID string `json:"grantId"`
Scope string `json:"scope"` // "PROJECT" | "GLOBAL"
ToolName string `json:"toolName"`
ProjectID string `json:"projectId"`
Tiers []string `json:"tiers"`
Reason string `json:"reason"`
ExpiresAtMs *int64 `json:"expiresAtMs"`
}
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
type IdeaDto struct {
IdeaID string `json:"ideaId"`
Text string `json:"text"`
CapturedAtMs int64 `json:"capturedAtMs"`
}
// ProposedWorkflowDto is one candidate workflow the router suggests, with a short
// reason. Mirrors the Kotlin ProposedWorkflow (core:events), the wire shape.
type ProposedWorkflowDto struct {
WorkflowID string `json:"workflowId"`
Reason string `json:"reason"`
}
// ClarificationQuestionDto is one open question from a stage. Empty Options ->
// free-text answer; non-empty -> selectable choices. Mirrors the Kotlin
// ClarificationQuestion (core:events), which is the wire shape.
type ClarificationQuestionDto struct {
ID string `json:"id"`
Prompt string `json:"prompt"`
Options []string `json:"options"`
MultiSelect bool `json:"multiSelect"`
Header string `json:"header"`
}
// ClarificationAnswerDto is the operator's answer to one question.
type ClarificationAnswerDto struct {
QuestionID string `json:"questionId"`
Value string `json:"value"`
}
// StatsDto mirrors the server's MetricsReport: a session's derived metrics
// (observability-spec §2/§3). Raw sums plus the derived ratios the pane renders.
type StatsDto struct {
SessionID string `json:"sessionId"`
EventCount int64 `json:"eventCount"`
SessionDurationMs int64 `json:"sessionDurationMs"`
InferenceCount int64 `json:"inferenceCount"`
InferenceMs int64 `json:"inferenceMs"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
TokensPerSecond float64 `json:"tokensPerSecond"`
PerProvider []ProviderStatsDto `json:"perProvider"`
ToolCount int64 `json:"toolCount"`
ToolMs int64 `json:"toolMs"`
PerTool []ToolStatsDto `json:"perTool"`
ApprovalsRequested int64 `json:"approvalsRequested"`
ApprovalsResolved int64 `json:"approvalsResolved"`
ApprovalsPending int64 `json:"approvalsPending"`
ApprovalWaitMs int64 `json:"approvalWaitMs"`
AvgApprovalWaitMs int64 `json:"avgApprovalWaitMs"`
PerTier []TierApprovalStatsDto `json:"perTier"`
Failures FailureMetricsDto `json:"failures"`
InferencePct float64 `json:"inferencePct"`
ToolPct float64 `json:"toolPct"`
ApprovalWaitPct float64 `json:"approvalWaitPct"`
}
type ProviderStatsDto struct {
Provider string `json:"provider"`
CompletedCount int64 `json:"completedCount"`
TotalLatencyMs int64 `json:"totalLatencyMs"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
TokensPerSecond float64 `json:"tokensPerSecond"`
}
type ToolStatsDto struct {
ToolName string `json:"toolName"`
CompletedCount int64 `json:"completedCount"`
TotalDurationMs int64 `json:"totalDurationMs"`
}
type TierApprovalStatsDto struct {
Tier string `json:"tier"`
RequestedCount int64 `json:"requestedCount"`
ResolvedCount int64 `json:"resolvedCount"`
TotalWaitMs int64 `json:"totalWaitMs"`
AvgWaitMs int64 `json:"avgWaitMs"`
}
type FailureMetricsDto struct {
InferenceFailures int64 `json:"inferenceFailures"`
InferenceTimeouts int64 `json:"inferenceTimeouts"`
ToolFailures int64 `json:"toolFailures"`
ToolRejections int64 `json:"toolRejections"`
StageFailures int64 `json:"stageFailures"`
WorkflowFailures int64 `json:"workflowFailures"`
}
// HealthSubjectDto is one monitored subject's health status. Mirrors Kotlin HealthSubjectReport.
// status is "HEALTHY" or "DEGRADED"; since is an ISO-8601 string for the last edge event.
type HealthSubjectDto struct {
Subject string `json:"subject"`
Status string `json:"status"`
Metric string `json:"metric"`
ObservedValue int64 `json:"observedValue"`
Detail string `json:"detail"`
Since string `json:"since"`
}
// HealthDto is the system health snapshot. Mirrors Kotlin HealthReport.
// overall is "HEALTHY" or "DEGRADED". subjects is empty when health monitoring is disabled
// or no probes have fired yet — the TUI renders a visible fallback in that case.
type HealthDto struct {
Overall string `json:"overall"`
Subjects []HealthSubjectDto `json:"subjects"`
CheckedAt string `json:"checkedAt"`
}
type ConfigFieldDto struct {
Key string `json:"key"`
Type string `json:"type"`
Value string `json:"value"`
Options []string `json:"options"`
RestartRequired bool `json:"restartRequired"`
}
type ArtifactDto struct {
ArtifactID string `json:"artifactId"`
StageID string `json:"stageId"`
SchemaVersion int `json:"schemaVersion"`
Phase string `json:"phase"`
Content *string `json:"content"`
}
type SessionStateDto struct {
Status string `json:"status"`
CurrentStageID *string `json:"currentStageId"`
PauseReason *string `json:"pauseReason"`
}
type RiskSummaryDto struct {
Level string `json:"level"`
Factors []string `json:"factors"`
RecommendedAction string `json:"recommendedAction"`
Rationale []string `json:"rationale"`
}
type AssessedIssueDto struct {
Code string `json:"code"`
Message string `json:"message"`
Severity string `json:"severity"`
}
// ReviewFindingDto is a PR-comment-style finding from the semantic reviewer (Gate 3).
type ReviewFindingDto struct {
Severity string `json:"severity"`
Confidence float64 `json:"confidence"`
Category string `json:"category"`
Target string `json:"target"`
Message string `json:"message"`
SuggestedFix *string `json:"suggestedFix"`
Correctness bool `json:"correctness"`
}
type ProviderHealthDto struct {
ProviderID string `json:"providerId"`
Status string `json:"status"`
LoadPercent *int `json:"loadPercent"`
}
type ApprovalDto struct {
RequestID string `json:"requestId"`
Tier string `json:"tier"`
ToolName *string `json:"toolName"`
Preview *string `json:"preview"`
}
type StageToolDecl struct {
StageID string `json:"stageId"`
Tools []ToolDecl `json:"tools"`
TokenBudget *int `json:"tokenBudget"`
}
type ToolDecl struct {
Name string `json:"name"`
Tier int `json:"tier"`
}
type EventEntryDto struct {
Timestamp int64 `json:"timestamp"`
Type string `json:"type"`
Detail string `json:"detail"`
}
type ToolRecordDto struct {
Name string `json:"name"`
Tier int `json:"tier"`
Status string `json:"status"`
Diff *string `json:"diff"`
}
// TranscriptRowDto is one raw OUTPUT-transcript source row from a session_snapshot; the client
// folds these into RouterEntry rows on reopen, applying its own diff/param/action formatting.
type TranscriptRowDto struct {
Kind string `json:"kind"`
Role string `json:"role"`
Content string `json:"content"`
ToolName string `json:"toolName"`
Status string `json:"status"`
Diff *string `json:"diff"`
Params []string `json:"params"`
Summary string `json:"summary"`
AffectedEntities []string `json:"affectedEntities"`
LatencyMs *int64 `json:"latencyMs"`
TotalTokens *int `json:"totalTokens"`
}
type WorkflowDto struct {
WorkflowID string `json:"workflowId"`
Description string `json:"description"`
}
// Decode parses one server frame into a ServerMessage.
func Decode(raw []byte) (ServerMessage, error) {
var m ServerMessage
err := json.Unmarshal(raw, &m)
return m, err
}
// IsEventBearing reports whether a message carries a session event (and so must
// be buffered during the snapshot phase). Control/snapshot messages return false
// and are applied immediately.
func (m ServerMessage) IsEventBearing() bool {
switch m.Type {
case TypeSessionAnnounced, TypeChatTurn, TypeSessionPaused, TypeSessionResumed,
TypeSessionCompleted, TypeSessionFailed,
TypeStageStarted, TypeStageCompleted, TypeStageFailed,
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
TypeInferenceFailed, TypeInferenceRetry,
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
TypeToolAssessed,
TypeApprovalRequired, TypeApprovalResolved, TypeClarification,
TypeWorkflowProposed,
TypeSessionRenamed, TypeWorkspaceBound, TypeArtifactCreated,
TypeArtifactValid, TypePlanLocked,
TypeRouterNarration, TypeReviewFindings:
return true
default:
return false
}
}
// --- Client -> server encoders ---
func encode(typeName string, fields map[string]any) []byte {
fields["type"] = clientPrefix + typeName
b, _ := json.Marshal(fields)
return b
}
// StartChatSession opens a new chat session with a client-generated sessionId.
func StartChatSession(sessionID, text string) []byte {
return encode("StartChatSession", map[string]any{"sessionId": sessionID, "text": text})
}
// ChatInput sends a turn into an existing session. mode is "CHAT" or "STEERING".
func ChatInput(sessionID, text, mode string) []byte {
return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode})
}
// StartSession launches a workflow-backed session. input is the optional freeform request
// seeded into the decision journal; pass "" for fixed-task workflows (e.g. healthcheck).
func StartSession(workflowID, input string) []byte {
msg := map[string]any{"workflowId": workflowID, "config": nil}
if input != "" {
msg["input"] = input
}
return encode("StartSession", msg)
}
// CancelSession cancels a running session.
func CancelSession(sessionID string) []byte {
return encode("CancelSession", map[string]any{"sessionId": sessionID})
}
// ListArtifacts asks the server for the artifacts produced by a session, with content.
func ListArtifacts(sessionID string) []byte {
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
}
// ListFiles asks the server for the session workspace's file paths (replied to with file.list),
// used to populate the @ file-reference picker.
func ListFiles(sessionID string) []byte {
return encode("ListFiles", map[string]any{"sessionId": sessionID})
}
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
func GetSessionStats(sessionID string) []byte {
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
}
// GetConfig requests the current editable config (replied to with a config.snapshot).
func GetConfig() []byte {
return encode("GetConfig", map[string]any{})
}
// ListIdeas requests the cross-session idea board (replied to with an idea.list).
func ListIdeas() []byte {
return encode("ListIdeas", map[string]any{})
}
// DiscardIdea removes an idea from the board (server tombstones it; replies with a fresh idea.list).
func DiscardIdea(ideaID string) []byte {
return encode("DiscardIdea", map[string]any{"ideaId": ideaID})
}
// UpdateConfig asks the server to apply and persist a config patch (key -> string value).
func UpdateConfig(patch map[string]string) []byte {
return encode("UpdateConfig", map[string]any{"patch": patch})
}
// GetProjectProfile requests the ProjectProfile bound to a session's workspace (replied to with
// a project_profile.snapshot).
func GetProjectProfile(sessionID string) []byte {
return encode("GetProjectProfile", map[string]any{"sessionId": sessionID})
}
// UpdateProjectProfile overwrites the ProjectProfile at a session's workspace root.
func UpdateProjectProfile(sessionID, about string, conventions []string, commands map[string]string) []byte {
return encode("UpdateProjectProfile", map[string]any{
"sessionId": sessionID,
"about": about,
"conventions": conventions,
"commands": commands,
})
}
// GetOperatorProfile requests the operator's OperatorProfile (replied to with an
// operator_profile.snapshot). Not session-scoped.
func GetOperatorProfile() []byte {
return encode("GetOperatorProfile", map[string]any{})
}
// UpdateOperatorProfile overwrites the OperatorProfile.
func UpdateOperatorProfile(about, approvalMode string, preferredModels, conventions []string) []byte {
return encode("UpdateOperatorProfile", map[string]any{
"about": about,
"approvalMode": approvalMode,
"preferredModels": preferredModels,
"conventions": conventions,
})
}
// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
return encode("ApprovalResponse", map[string]any{
"requestId": requestID,
"decision": decision,
"steeringNote": steeringNote,
})
}
// ClarificationResponse answers a stage's open questions; the parked stage re-runs
// with the answers in context.
func ClarificationResponse(sessionID, stageID, requestID string, answers []ClarificationAnswerDto) []byte {
return encode("ClarificationResponse", map[string]any{
"sessionId": sessionID,
"stageId": stageID,
"requestId": requestID,
"answers": answers,
})
}
// Ping is a keepalive.
func Ping(ts int64) []byte {
return encode("Ping", map[string]any{"timestamp": ts})
}
// SwapModel asks the server to make modelID resident and pin it.
func SwapModel(modelID string) []byte {
return encode("SwapModel", map[string]any{"modelId": modelID})
}
// ClearModelPin releases the manual-swap pin so per-stage selection resumes.
func ClearModelPin() []byte {
return encode("ClearModelPin", map[string]any{})
}
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3".
// For PROJECT/GLOBAL the server derives the projectId from the session's workspace.
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
return encode("CreateGrant", map[string]any{
"sessionId": sessionID,
"scope": scope,
"stageId": nil,
"permittedTiers": permittedTiers,
"reason": reason,
"expiresAt": nil,
"toolName": toolName,
})
}
// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list.
func RevokeGrant(grantID string) []byte {
return encode("RevokeGrant", map[string]any{"grantId": grantID})
}
// ListGrants requests the active cross-session grants (replied to with a grant.list).
func ListGrants() []byte {
return encode("ListGrants", map[string]any{})
}
// Hello is the first frame sent on every (re)connect. It carries the client's
// working directory so the server can bind a workspace before any session starts.
func Hello(workingDir string) []byte {
return encode("Hello", map[string]any{"workingDir": workingDir})
}
// GetHealthChecks requests the system health-check report (replied to with health.checks).
func GetHealthChecks() []byte {
return encode("GetHealthChecks", map[string]any{})
}