mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-06 02:34:09 +08:00
- Move all source files to internal/{app,backend,config,executor,logger,parser,utils}
- Integrate third-party libraries: zerolog, goccy/go-json, gopsutil, cobra/viper
- Add comprehensive unit tests for utils package (94.3% coverage)
- Add performance benchmarks for string operations
- Fix error display: cleanup warnings no longer pollute Recent Errors
- Add GitHub Actions CI workflow
- Add Makefile for build automation
- Add README documentation
Generated with SWE-Agent.ai
Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
524 lines
11 KiB
Go
524 lines
11 KiB
Go
package wrapper
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
utils "codeagent-wrapper/internal/utils"
|
|
)
|
|
|
|
func resolveTimeout() int {
|
|
raw := os.Getenv("CODEX_TIMEOUT")
|
|
if raw == "" {
|
|
return defaultTimeout
|
|
}
|
|
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil || parsed <= 0 {
|
|
logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout))
|
|
return defaultTimeout
|
|
}
|
|
|
|
if parsed > 10000 {
|
|
return parsed / 1000
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func readPipedTask() (string, error) {
|
|
if isTerminal() {
|
|
logInfo("Stdin is tty, skipping pipe read")
|
|
return "", nil
|
|
}
|
|
logInfo("Reading from stdin pipe...")
|
|
data, err := io.ReadAll(stdinReader)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read stdin: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
logInfo("Stdin pipe returned empty data")
|
|
return "", nil
|
|
}
|
|
logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data)))
|
|
return string(data), nil
|
|
}
|
|
|
|
func shouldUseStdin(taskText string, piped bool) bool {
|
|
if piped {
|
|
return true
|
|
}
|
|
if len(taskText) > 800 {
|
|
return true
|
|
}
|
|
return strings.ContainsAny(taskText, stdinSpecialChars)
|
|
}
|
|
|
|
func defaultIsTerminal() bool {
|
|
fi, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
|
}
|
|
|
|
func isTerminal() bool {
|
|
return isTerminalFn()
|
|
}
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
if val := os.Getenv(key); val != "" {
|
|
return val
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
type logWriter struct {
|
|
prefix string
|
|
maxLen int
|
|
buf bytes.Buffer
|
|
dropped bool
|
|
}
|
|
|
|
func newLogWriter(prefix string, maxLen int) *logWriter {
|
|
if maxLen <= 0 {
|
|
maxLen = codexLogLineLimit
|
|
}
|
|
return &logWriter{prefix: prefix, maxLen: maxLen}
|
|
}
|
|
|
|
func (lw *logWriter) Write(p []byte) (int, error) {
|
|
if lw == nil {
|
|
return len(p), nil
|
|
}
|
|
total := len(p)
|
|
for len(p) > 0 {
|
|
if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
|
|
lw.writeLimited(p[:idx])
|
|
lw.logLine(true)
|
|
p = p[idx+1:]
|
|
continue
|
|
}
|
|
lw.writeLimited(p)
|
|
break
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (lw *logWriter) Flush() {
|
|
if lw == nil || lw.buf.Len() == 0 {
|
|
return
|
|
}
|
|
lw.logLine(false)
|
|
}
|
|
|
|
func (lw *logWriter) logLine(force bool) {
|
|
if lw == nil {
|
|
return
|
|
}
|
|
line := lw.buf.String()
|
|
dropped := lw.dropped
|
|
lw.dropped = false
|
|
lw.buf.Reset()
|
|
if line == "" && !force {
|
|
return
|
|
}
|
|
if lw.maxLen > 0 {
|
|
if dropped {
|
|
if lw.maxLen > 3 {
|
|
line = line[:min(len(line), lw.maxLen-3)] + "..."
|
|
} else {
|
|
line = line[:min(len(line), lw.maxLen)]
|
|
}
|
|
} else if len(line) > lw.maxLen {
|
|
cutoff := lw.maxLen
|
|
if cutoff > 3 {
|
|
line = line[:cutoff-3] + "..."
|
|
} else {
|
|
line = line[:cutoff]
|
|
}
|
|
}
|
|
}
|
|
logInfo(lw.prefix + line)
|
|
}
|
|
|
|
func (lw *logWriter) writeLimited(p []byte) {
|
|
if lw == nil || len(p) == 0 {
|
|
return
|
|
}
|
|
if lw.maxLen <= 0 {
|
|
lw.buf.Write(p)
|
|
return
|
|
}
|
|
|
|
remaining := lw.maxLen - lw.buf.Len()
|
|
if remaining <= 0 {
|
|
lw.dropped = true
|
|
return
|
|
}
|
|
if len(p) <= remaining {
|
|
lw.buf.Write(p)
|
|
return
|
|
}
|
|
lw.buf.Write(p[:remaining])
|
|
lw.dropped = true
|
|
}
|
|
|
|
type tailBuffer struct {
|
|
limit int
|
|
data []byte
|
|
}
|
|
|
|
func (b *tailBuffer) Write(p []byte) (int, error) {
|
|
if b.limit <= 0 {
|
|
return len(p), nil
|
|
}
|
|
|
|
if len(p) >= b.limit {
|
|
b.data = append(b.data[:0], p[len(p)-b.limit:]...)
|
|
return len(p), nil
|
|
}
|
|
|
|
total := len(b.data) + len(p)
|
|
if total <= b.limit {
|
|
b.data = append(b.data, p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
overflow := total - b.limit
|
|
b.data = append(b.data[overflow:], p...)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *tailBuffer) String() string {
|
|
return string(b.data)
|
|
}
|
|
|
|
func truncate(s string, maxLen int) string {
|
|
return utils.Truncate(s, maxLen)
|
|
}
|
|
|
|
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
|
|
func safeTruncate(s string, maxLen int) string {
|
|
return utils.SafeTruncate(s, maxLen)
|
|
}
|
|
|
|
// sanitizeOutput removes ANSI escape sequences and control characters.
|
|
func sanitizeOutput(s string) string {
|
|
return utils.SanitizeOutput(s)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
return utils.Min(a, b)
|
|
}
|
|
|
|
func hello() string {
|
|
return "hello world"
|
|
}
|
|
|
|
func greet(name string) string {
|
|
return "hello " + name
|
|
}
|
|
|
|
func farewell(name string) string {
|
|
return "goodbye " + name
|
|
}
|
|
|
|
// extractCoverageFromLines extracts coverage from pre-split lines.
|
|
func extractCoverageFromLines(lines []string) string {
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
|
|
end := len(lines)
|
|
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
|
|
end--
|
|
}
|
|
|
|
if end == 1 {
|
|
trimmed := strings.TrimSpace(lines[0])
|
|
if strings.HasSuffix(trimmed, "%") {
|
|
if num, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, "%"), 64); err == nil && num >= 0 && num <= 100 {
|
|
return trimmed
|
|
}
|
|
}
|
|
}
|
|
|
|
coverageKeywords := []string{"file", "stmt", "branch", "line", "coverage", "total"}
|
|
|
|
for _, line := range lines[:end] {
|
|
lower := strings.ToLower(line)
|
|
|
|
hasKeyword := false
|
|
tokens := strings.FieldsFunc(lower, func(r rune) bool { return r < 'a' || r > 'z' })
|
|
for _, token := range tokens {
|
|
for _, kw := range coverageKeywords {
|
|
if strings.HasPrefix(token, kw) {
|
|
hasKeyword = true
|
|
break
|
|
}
|
|
}
|
|
if hasKeyword {
|
|
break
|
|
}
|
|
}
|
|
if !hasKeyword {
|
|
continue
|
|
}
|
|
if !strings.Contains(line, "%") {
|
|
continue
|
|
}
|
|
|
|
// Extract percentage pattern: number followed by %
|
|
for i := 0; i < len(line); i++ {
|
|
if line[i] == '%' && i > 0 {
|
|
// Walk back to find the number
|
|
j := i - 1
|
|
for j >= 0 && (line[j] == '.' || (line[j] >= '0' && line[j] <= '9')) {
|
|
j--
|
|
}
|
|
if j < i-1 {
|
|
numStr := line[j+1 : i]
|
|
// Validate it's a reasonable percentage
|
|
if num, err := strconv.ParseFloat(numStr, 64); err == nil && num >= 0 && num <= 100 {
|
|
return numStr + "%"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// extractCoverage extracts coverage percentage from task output
|
|
// Supports common formats: "Coverage: 92%", "92% coverage", "coverage 92%", "TOTAL 92%"
|
|
func extractCoverage(message string) string {
|
|
if message == "" {
|
|
return ""
|
|
}
|
|
|
|
return extractCoverageFromLines(strings.Split(message, "\n"))
|
|
}
|
|
|
|
// extractCoverageNum extracts coverage as a numeric value for comparison
|
|
func extractCoverageNum(coverage string) float64 {
|
|
if coverage == "" {
|
|
return 0
|
|
}
|
|
// Remove % sign and parse
|
|
numStr := strings.TrimSuffix(coverage, "%")
|
|
if num, err := strconv.ParseFloat(numStr, 64); err == nil {
|
|
return num
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// extractFilesChangedFromLines extracts files from pre-split lines.
|
|
func extractFilesChangedFromLines(lines []string) []string {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var files []string
|
|
seen := make(map[string]bool)
|
|
exts := []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".java", ".vue", ".css", ".scss", ".md", ".json", ".yaml", ".yml", ".toml"}
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Pattern 1: "Modified: path/to/file.ts" or "Created: path/to/file.ts"
|
|
matchedPrefix := false
|
|
for _, prefix := range []string{"Modified:", "Created:", "Updated:", "Edited:", "Wrote:", "Changed:"} {
|
|
if strings.HasPrefix(line, prefix) {
|
|
file := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
file = strings.Trim(file, "`\"'()[],:")
|
|
file = strings.TrimPrefix(file, "@")
|
|
if file != "" && !seen[file] {
|
|
files = append(files, file)
|
|
seen[file] = true
|
|
}
|
|
matchedPrefix = true
|
|
break
|
|
}
|
|
}
|
|
if matchedPrefix {
|
|
continue
|
|
}
|
|
|
|
// Pattern 2: Tokens that look like file paths (allow root files, strip @ prefix).
|
|
parts := strings.Fields(line)
|
|
for _, part := range parts {
|
|
part = strings.Trim(part, "`\"'()[],:")
|
|
part = strings.TrimPrefix(part, "@")
|
|
for _, ext := range exts {
|
|
if strings.HasSuffix(part, ext) && !seen[part] {
|
|
files = append(files, part)
|
|
seen[part] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Limit to first 10 files to avoid bloat
|
|
if len(files) > 10 {
|
|
files = files[:10]
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
// extractFilesChanged extracts list of changed files from task output
|
|
// Looks for common patterns like "Modified: file.ts", "Created: file.ts", file paths in output
|
|
func extractFilesChanged(message string) []string {
|
|
if message == "" {
|
|
return nil
|
|
}
|
|
|
|
return extractFilesChangedFromLines(strings.Split(message, "\n"))
|
|
}
|
|
|
|
// extractTestResultsFromLines extracts test results from pre-split lines.
|
|
func extractTestResultsFromLines(lines []string) (passed, failed int) {
|
|
if len(lines) == 0 {
|
|
return 0, 0
|
|
}
|
|
|
|
// Common patterns:
|
|
// pytest: "12 passed, 2 failed"
|
|
// jest: "Tests: 2 failed, 12 passed"
|
|
// go: "ok ... 12 tests"
|
|
|
|
for _, line := range lines {
|
|
line = strings.ToLower(line)
|
|
|
|
// Look for test result lines
|
|
if !strings.Contains(line, "pass") && !strings.Contains(line, "fail") && !strings.Contains(line, "test") {
|
|
continue
|
|
}
|
|
|
|
// Extract numbers near "passed" or "pass"
|
|
if idx := strings.Index(line, "pass"); idx != -1 {
|
|
// Look for number before "pass"
|
|
num := extractNumberBefore(line, idx)
|
|
if num > 0 {
|
|
passed = num
|
|
}
|
|
}
|
|
|
|
// Extract numbers near "failed" or "fail"
|
|
if idx := strings.Index(line, "fail"); idx != -1 {
|
|
num := extractNumberBefore(line, idx)
|
|
if num > 0 {
|
|
failed = num
|
|
}
|
|
}
|
|
|
|
// go test style: "ok ... 12 tests"
|
|
if passed == 0 {
|
|
if idx := strings.Index(line, "test"); idx != -1 {
|
|
num := extractNumberBefore(line, idx)
|
|
if num > 0 {
|
|
passed = num
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found both, stop
|
|
if passed > 0 && failed > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
return passed, failed
|
|
}
|
|
|
|
// extractTestResults extracts test pass/fail counts from task output
|
|
func extractTestResults(message string) (passed, failed int) {
|
|
if message == "" {
|
|
return 0, 0
|
|
}
|
|
|
|
return extractTestResultsFromLines(strings.Split(message, "\n"))
|
|
}
|
|
|
|
// extractNumberBefore extracts a number that appears before the given index
|
|
func extractNumberBefore(s string, idx int) int {
|
|
if idx <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Walk backwards to find digits
|
|
end := idx - 1
|
|
for end >= 0 && (s[end] == ' ' || s[end] == ':' || s[end] == ',') {
|
|
end--
|
|
}
|
|
if end < 0 {
|
|
return 0
|
|
}
|
|
|
|
start := end
|
|
for start >= 0 && s[start] >= '0' && s[start] <= '9' {
|
|
start--
|
|
}
|
|
start++
|
|
|
|
if start > end {
|
|
return 0
|
|
}
|
|
|
|
numStr := s[start : end+1]
|
|
if num, err := strconv.Atoi(numStr); err == nil {
|
|
return num
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// extractKeyOutputFromLines extracts key output from pre-split lines.
|
|
func extractKeyOutputFromLines(lines []string, maxLen int) string {
|
|
if len(lines) == 0 || maxLen <= 0 {
|
|
return ""
|
|
}
|
|
|
|
// Priority 1: Look for explicit summary lines
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
lower := strings.ToLower(line)
|
|
if strings.HasPrefix(lower, "summary:") || strings.HasPrefix(lower, "completed:") ||
|
|
strings.HasPrefix(lower, "implemented:") || strings.HasPrefix(lower, "added:") ||
|
|
strings.HasPrefix(lower, "created:") || strings.HasPrefix(lower, "fixed:") {
|
|
content := line
|
|
for _, prefix := range []string{"Summary:", "Completed:", "Implemented:", "Added:", "Created:", "Fixed:",
|
|
"summary:", "completed:", "implemented:", "added:", "created:", "fixed:"} {
|
|
content = strings.TrimPrefix(content, prefix)
|
|
}
|
|
content = strings.TrimSpace(content)
|
|
if len(content) > 0 {
|
|
return safeTruncate(content, maxLen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 2: First meaningful line (skip noise)
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") ||
|
|
strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
// Skip very short lines (likely headers or markers)
|
|
if len(line) < 20 {
|
|
continue
|
|
}
|
|
return safeTruncate(line, maxLen)
|
|
}
|
|
|
|
// Fallback: truncate entire message
|
|
clean := strings.TrimSpace(strings.Join(lines, "\n"))
|
|
return safeTruncate(clean, maxLen)
|
|
}
|