Files
kandev/apps/backend/internal/task/models/message_shell_output.go
T
2026-09-17 10:39:30 +01:00

203 lines
5.5 KiB
Go

package models
import (
"encoding/json"
"strconv"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/entityrefs"
)
// ShellExecOutputSnapshot is the full bounded output stored on a shell message.
type ShellExecOutputSnapshot struct {
ExitCode *int `json:"exit_code,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// ProjectMessageMetadata removes shell output bodies from client message payloads.
// It copies the normalized shell path so repository-owned metadata is never mutated.
func ProjectMessageMetadata(metadata map[string]any) map[string]any {
projected := metadata
copied := false
if rawReferences, exists := metadata["entity_references"]; exists {
projected = copyMetadata(metadata)
copied = true
references := entityrefs.NormalizePersisted(rawReferences)
if len(references) == 0 {
delete(projected, "entity_references")
} else {
projected["entity_references"] = references
}
}
output, ok := shellOutputFromMetadata(metadata)
if !ok {
return projected
}
normalized, ok := projectNormalizedShell(metadata["normalized"], shellOutputSummary(output))
if !ok {
return projected
}
if !copied {
projected = copyMetadata(metadata)
}
projected["normalized"] = normalized
return projected
}
// ExtractShellExecOutput returns the full stored snapshot for normalized shell metadata.
func ExtractShellExecOutput(metadata map[string]any) (ShellExecOutputSnapshot, bool) {
return shellOutputFromMetadata(metadata)
}
func shellOutputFromMetadata(metadata map[string]any) (ShellExecOutputSnapshot, bool) {
if metadata == nil {
return ShellExecOutputSnapshot{}, false
}
raw, ok := metadata["normalized"]
if !ok || raw == nil {
return ShellExecOutputSnapshot{}, false
}
switch normalized := raw.(type) {
case *streams.NormalizedPayload:
if normalized == nil || normalized.ShellExec() == nil {
return ShellExecOutputSnapshot{}, false
}
return shellOutputFromTyped(normalized.ShellExec().Output), true
case map[string]any:
shell, shellOK := normalized["shell_exec"].(map[string]any)
if !shellOK {
return ShellExecOutputSnapshot{}, false
}
if output, outputOK := shell["output"].(map[string]any); outputOK && isProjectedShellOutput(output) {
return ShellExecOutputSnapshot{}, false
}
return shellOutputFromMap(shell["output"]), true
default:
return ShellExecOutputSnapshot{}, false
}
}
func shellOutputFromTyped(output *streams.ShellExecOutput) ShellExecOutputSnapshot {
if output == nil {
return ShellExecOutputSnapshot{}
}
return ShellExecOutputSnapshot{
ExitCode: output.ExitCode,
Stdout: output.Stdout,
Stderr: output.Stderr,
Truncated: output.Truncated,
}
}
func shellOutputFromMap(raw any) ShellExecOutputSnapshot {
output, ok := raw.(map[string]any)
if !ok {
return ShellExecOutputSnapshot{}
}
result := ShellExecOutputSnapshot{
Stdout: StringFromAny(output["stdout"]),
Stderr: StringFromAny(output["stderr"]),
Truncated: boolFromAny(output["truncated"]),
}
if exitCode, ok := intFromAny(output["exit_code"]); ok {
result.ExitCode = &exitCode
}
return result
}
func isProjectedShellOutput(output map[string]any) bool {
if _, hasStdout := output["stdout"]; hasStdout {
return false
}
if _, hasStderr := output["stderr"]; hasStderr {
return false
}
_, hasOutput := output["has_output"]
_, hasStdoutBytes := output["stdout_bytes"]
_, hasStderrBytes := output["stderr_bytes"]
return hasOutput || hasStdoutBytes || hasStderrBytes
}
func shellOutputSummary(output ShellExecOutputSnapshot) map[string]any {
summary := map[string]any{
"has_output": output.Stdout != "" || output.Stderr != "",
"stdout_bytes": len(output.Stdout),
"stderr_bytes": len(output.Stderr),
"truncated": output.Truncated,
}
if output.ExitCode != nil {
summary["exit_code"] = *output.ExitCode
}
return summary
}
func projectNormalizedShell(raw any, summary map[string]any) (map[string]any, bool) {
switch normalized := raw.(type) {
case *streams.NormalizedPayload:
if normalized == nil || normalized.ShellExec() == nil {
return nil, false
}
return map[string]any{
"kind": string(streams.ToolKindShellExec),
"shell_exec": projectTypedShell(normalized.ShellExec(), summary),
}, true
case map[string]any:
shell, ok := normalized["shell_exec"].(map[string]any)
if !ok {
return nil, false
}
projectedShell := copyMetadata(shell)
projectedShell["output"] = summary
projected := copyMetadata(normalized)
projected["shell_exec"] = projectedShell
return projected, true
default:
return nil, false
}
}
func projectTypedShell(shell *streams.ShellExecPayload, summary map[string]any) map[string]any {
projected := map[string]any{
"command": shell.Command,
"output": summary,
}
if shell.WorkDir != "" {
projected["work_dir"] = shell.WorkDir
}
if shell.Description != "" {
projected["description"] = shell.Description
}
if shell.Timeout != 0 {
projected["timeout"] = shell.Timeout
}
if shell.Background {
projected["background"] = true
}
return projected
}
func intFromAny(raw any) (int, bool) {
switch value := raw.(type) {
case int:
return value, true
case int32:
return int(value), true
case int64:
return int(value), true
case float64:
return int(value), value == float64(int(value))
case json.Number:
parsed, err := strconv.ParseInt(string(value), 10, strconv.IntSize)
return int(parsed), err == nil
default:
return 0, false
}
}
func boolFromAny(raw any) bool {
value, _ := raw.(bool)
return value
}