diff --git a/agent/sandbox/v2/claude/command.go b/agent/sandbox/v2/claude/command.go index 0ba92a02..f2c42534 100644 --- a/agent/sandbox/v2/claude/command.go +++ b/agent/sandbox/v2/claude/command.go @@ -72,7 +72,7 @@ func (r *ClaudeRunner) buildCommand(ctx context.Context, req *types.StreamReques env := buildEnv(req, p) args := buildArgs(req, r, p, isContinuation, assistantID, chatID) - inputJSONL := buildInput(req.Messages, isContinuation) + inputJSONL := buildLastUserMessageJSONL(req.Messages) var systemPrompt string envPrompt := buildSandboxEnvPrompt(p, workDir) @@ -236,13 +236,6 @@ func buildArgs(req *types.StreamRequest, r *ClaudeRunner, p platform, isContinua return args } -func buildInput(messages []agentContext.Message, isContinuation bool) string { - if isContinuation { - return buildLastUserMessageJSONL(messages) - } - return buildFirstRequestJSONL(messages) -} - func buildSandboxEnvPrompt(p platform, workDir string) string { osName := p.OS() if osName == "" { @@ -323,32 +316,6 @@ func buildMCPAllowedTools(servers []types.MCPServer) string { return strings.Join(patterns, ",") } -// buildFirstRequestJSONL builds the input JSONL for a new (non-continuation) -// Claude CLI session. Per the stream-json input protocol, only user messages -// should be sent; Claude CLI manages its own assistant history internally. -func buildFirstRequestJSONL(messages []agentContext.Message) string { - var lines []string - for _, msg := range messages { - if msg.Role != "user" { - continue - } - content := msg.Content - if content == nil { - content = "" - } - streamMsg := map[string]any{ - "type": "user", - "message": map[string]any{ - "role": "user", - "content": content, - }, - } - data, _ := json.Marshal(streamMsg) - lines = append(lines, string(data)) - } - return strings.Join(lines, "\n") -} - func buildLastUserMessageJSONL(messages []agentContext.Message) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { diff --git a/agent/sandbox/v2/claude/command_test.go b/agent/sandbox/v2/claude/command_test.go index 9ecdfdd5..bbfd3460 100644 --- a/agent/sandbox/v2/claude/command_test.go +++ b/agent/sandbox/v2/claude/command_test.go @@ -170,86 +170,43 @@ func TestBuildArgs_WhitelistOptions(t *testing.T) { assert.Contains(t, args, "--max-turns") } -// --- buildInput --- +// --- buildLastUserMessageJSONL (was buildInput / buildFirstRequestJSONL) --- -func TestBuildInput_FirstRequest(t *testing.T) { - msgs := []agentContext.Message{ - {Role: "user", Content: "hello"}, - {Role: "assistant", Content: "hi"}, - } - result := buildInput(msgs, false) - lines := strings.Split(strings.TrimSpace(result), "\n") - assert.Len(t, lines, 1, "first request only includes user messages") - assert.Contains(t, lines[0], "hello") - assert.NotContains(t, result, "hi") -} - -func TestBuildInput_Continuation(t *testing.T) { - msgs := []agentContext.Message{ - {Role: "user", Content: "first question"}, - {Role: "assistant", Content: "first answer"}, - {Role: "user", Content: "follow up"}, - } - result := buildInput(msgs, true) - var parsed map[string]any - err := json.Unmarshal([]byte(result), &parsed) - require.NoError(t, err) - assert.Equal(t, "user", parsed["type"]) - msg, _ := parsed["message"].(map[string]any) - assert.Equal(t, "follow up", msg["content"]) -} - -// --- buildFirstRequestJSONL --- - -func TestBuildFirstRequestJSONL_SkipsSystem(t *testing.T) { +func TestBuildLastUserMessageJSONL_SkipsSystem(t *testing.T) { msgs := []agentContext.Message{ {Role: "system", Content: "system prompt"}, {Role: "user", Content: "hello"}, } - result := buildFirstRequestJSONL(msgs) - lines := strings.Split(strings.TrimSpace(result), "\n") - assert.Len(t, lines, 1, "system messages should be skipped") - assert.Contains(t, lines[0], "hello") + result := buildLastUserMessageJSONL(msgs) + var parsed map[string]any + err := json.Unmarshal([]byte(strings.TrimSpace(result)), &parsed) + require.NoError(t, err) + assert.Equal(t, "user", parsed["type"]) + msg, _ := parsed["message"].(map[string]any) + assert.Equal(t, "hello", msg["content"]) } -func TestBuildFirstRequestJSONL_OnlyUserMessages(t *testing.T) { +func TestBuildLastUserMessageJSONL_OnlyLastUser(t *testing.T) { msgs := []agentContext.Message{ {Role: "user", Content: "q1"}, {Role: "assistant", Content: "a1"}, {Role: "user", Content: "q2"}, } - result := buildFirstRequestJSONL(msgs) - lines := strings.Split(strings.TrimSpace(result), "\n") - assert.Len(t, lines, 2, "only user messages should be included") - for _, line := range lines { - var parsed map[string]any - err := json.Unmarshal([]byte(line), &parsed) - require.NoError(t, err) - assert.Equal(t, "user", parsed["type"]) - msg, _ := parsed["message"].(map[string]any) - assert.Equal(t, "user", msg["role"]) - } + result := buildLastUserMessageJSONL(msgs) + var parsed map[string]any + err := json.Unmarshal([]byte(strings.TrimSpace(result)), &parsed) + require.NoError(t, err) + assert.Equal(t, "user", parsed["type"]) + msg, _ := parsed["message"].(map[string]any) + assert.Equal(t, "q2", msg["content"]) + assert.NotContains(t, result, "q1") } -func TestBuildFirstRequestJSONL_AssistantOnlyMessages(t *testing.T) { - msgs := []agentContext.Message{ - {Role: "assistant", Content: "a1"}, - {Role: "assistant", Content: "a2"}, - {Role: "assistant", Content: "a3"}, - {Role: "user", Content: "q1"}, - } - result := buildFirstRequestJSONL(msgs) - lines := strings.Split(strings.TrimSpace(result), "\n") - assert.Len(t, lines, 1, "only the user message should be included") - assert.Contains(t, lines[0], "q1") - assert.NotContains(t, result, "a1") -} - -func TestBuildFirstRequestJSONL_NilContent(t *testing.T) { +func TestBuildLastUserMessageJSONL_NilContent(t *testing.T) { msgs := []agentContext.Message{ {Role: "user", Content: nil}, } - result := buildFirstRequestJSONL(msgs) + result := buildLastUserMessageJSONL(msgs) var parsed map[string]any err := json.Unmarshal([]byte(strings.TrimSpace(result)), &parsed) require.NoError(t, err) diff --git a/cmd/root.go b/cmd/root.go index 7f32b1f4..0f619878 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -58,10 +58,16 @@ var langs = map[string]string{ "Force migrate": "强制更新数据表结构", "Migrate is not allowed on production mode.": "Migrate 不能再生产环境下使用", "Upgrade yao to latest version": "升级 yao 到最新版本", + "Current version:": "当前版本:", + "Latest version: ": "最新版本: ", + "Checking latest version...": "正在检查最新版本...", "🎉Current version is the latest🎉": "🎉当前版本是最新的🎉", "Do you want to update to %s ? (y/n): ": "是否更新到 %s ? (y/n): ", "Invalid input": "输入错误", "Canceled upgrade": "已取消更新", + "Downloading...": "正在下载...", + "Progress:": "进度:", + "Available assets:": "可用的制品:", "Error occurred while updating binary: %s": "更新二进制文件时出错: %s", "🎉Successfully updated to version: %s🎉": "🎉成功更新到版本: %s🎉", "Print all version information": "显示详细版本信息", @@ -201,7 +207,7 @@ func init() { agentCmd, mcpCmd, robotCmd, - // upgradeCmd, + upgradeCmd, ) // rootCmd.SetHelpCommand(helpCmd) rootCmd.PersistentFlags().StringVarP(&appPath, "app", "a", "", L("Application directory")) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 3e849897..986bb323 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -2,57 +2,257 @@ package cmd import ( "bufio" + "encoding/json" "fmt" + "io" + "net/http" "os" + "path/filepath" + "runtime" + "strings" + "time" - "github.com/blang/semver" "github.com/fatih/color" - "github.com/rhysd/go-github-selfupdate/selfupdate" "github.com/spf13/cobra" "github.com/yaoapp/yao/share" ) +const githubReleasesAPI = "https://api.github.com/repos/YaoApp/yao/releases/latest" + +// githubRelease represents a GitHub release response +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Prerelease bool `json:"prerelease"` + Assets []githubAsset `json:"assets"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` +} + +// githubAsset represents a single release asset +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + var upgradeCmd = &cobra.Command{ Use: "upgrade", - Short: L("Upgrade yao app to latest version"), - Long: L("Upgrade yao app to latest version"), + Short: L("Upgrade yao to latest version"), + Long: L("Upgrade yao to latest version"), Run: func(cmd *cobra.Command, args []string) { - Boot() - latest, found, err := selfupdate.DetectLatest("yaoapp/yao") + fmt.Printf("%s %s\n", color.WhiteString(L("Current version:")), color.CyanString(share.VERSION)) + fmt.Println(color.WhiteString(L("Checking latest version..."))) + + release, err := fetchLatestRelease() if err != nil { - if err != nil { - fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) - os.Exit(1) - } + fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) + os.Exit(1) } - currentVersion := semver.MustParse(share.VERSION) - if !found || latest.Version.LTE(currentVersion) { + + latestVersion := strings.TrimPrefix(release.TagName, "v") + fmt.Printf("%s %s\n", color.WhiteString(L("Latest version: ")), color.GreenString(latestVersion)) + + if latestVersion == share.VERSION { fmt.Println(color.GreenString(L("🎉Current version is the latest🎉"))) os.Exit(0) } - fmt.Println(color.WhiteString(L("Do you want to update to %s ? (y/n): "), latest.Version)) + + assetName := buildAssetName(latestVersion) + asset := findAsset(release.Assets, assetName) + if asset == nil { + fmt.Println(color.RedString(L("Fatal: %s"), fmt.Sprintf("asset not found: %s", assetName))) + fmt.Printf("%s %s\n", color.WhiteString(L("Available assets:")), "") + for _, a := range release.Assets { + if !strings.HasSuffix(a.Name, ".sha256") && !strings.HasSuffix(a.Name, ".zip") && !strings.HasSuffix(a.Name, ".tar.gz") { + fmt.Printf(" - %s\n", color.YellowString(a.Name)) + } + } + os.Exit(1) + } + + fmt.Printf("%s %s\n", color.WhiteString(L("Do you want to update to %s ? (y/n): "), latestVersion), "") + fmt.Print("> ") input, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) os.Exit(1) } - if input != "y\n" && input != "Y\n" && input != "n\n" && input != "N\n" { - fmt.Println(color.RedString(L("Fatal: %s"), L("Invalid input"))) - os.Exit(1) - } - if input == "n\n" || input == "N\n" { + + input = strings.TrimSpace(input) + if input != "y" && input != "Y" { fmt.Println(color.YellowString(L("Canceled upgrade"))) return } + exe, err := os.Executable() if err != nil { fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) os.Exit(1) } - if err := selfupdate.UpdateTo(latest.AssetURL, exe); err != nil { + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + fmt.Println(color.RedString(L("Fatal: %s"), err.Error())) + os.Exit(1) + } + + fmt.Printf("%s %s\n", color.WhiteString(L("Downloading...")), color.CyanString(asset.BrowserDownloadURL)) + if err := downloadAndReplace(asset.BrowserDownloadURL, exe); err != nil { fmt.Println(color.RedString(L("Error occurred while updating binary: %s"), err.Error())) os.Exit(1) } - fmt.Println(color.GreenString(L("🎉Successfully updated to version: %s🎉"), latest.Version)) + + fmt.Println(color.GreenString(L("🎉Successfully updated to version: %s🎉"), latestVersion)) }, } + +// fetchLatestRelease fetches the latest release from GitHub API +func fetchLatestRelease() (*githubRelease, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest("GET", githubReleasesAPI, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", fmt.Sprintf("yao/%s", share.VERSION)) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("failed to parse release info: %w", err) + } + return &release, nil +} + +// buildAssetName constructs the expected asset filename for the current platform +func buildAssetName(version string) string { + goos := runtime.GOOS + goarch := runtime.GOARCH + + // normalize arch names + if goarch == "amd64" { + goarch = "amd64" + } else if goarch == "arm64" { + goarch = "arm64" + } + + return fmt.Sprintf("yao-%s-%s-%s", version, goos, goarch) +} + +// findAsset finds the matching asset by name prefix +func findAsset(assets []githubAsset, name string) *githubAsset { + for i, a := range assets { + if a.Name == name { + return &assets[i] + } + } + return nil +} + +// downloadAndReplace downloads the new binary and replaces the current executable +func downloadAndReplace(url, exePath string) error { + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // write to a temp file in the same directory as the executable + dir := filepath.Dir(exePath) + tmpFile, err := os.CreateTemp(dir, ".yao-upgrade-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { + tmpFile.Close() + os.Remove(tmpPath) + }() + + total := resp.ContentLength + var downloaded int64 + buf := make([]byte, 32*1024) + lastPrint := time.Now() + + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, werr := tmpFile.Write(buf[:n]); werr != nil { + return fmt.Errorf("write failed: %w", werr) + } + downloaded += int64(n) + if time.Since(lastPrint) > 500*time.Millisecond || err == io.EOF { + if total > 0 { + pct := float64(downloaded) / float64(total) * 100 + fmt.Printf("\r %s %.1f%% (%d / %d MB)", + color.CyanString(L("Progress:")), + pct, + downloaded/1024/1024, + total/1024/1024, + ) + } else { + fmt.Printf("\r %s %d MB downloaded", color.CyanString(L("Progress:")), downloaded/1024/1024) + } + lastPrint = time.Now() + } + } + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("download interrupted: %w", err) + } + } + fmt.Println() + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // make executable + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to chmod: %w", err) + } + + // atomically replace the executable + if err := os.Rename(tmpPath, exePath); err != nil { + // on some systems (cross-device) rename fails, fall back to copy + if err2 := copyFile(tmpPath, exePath); err2 != nil { + return fmt.Errorf("replace failed: %w (copy fallback: %v)", err, err2) + } + } + + return nil +} + +// copyFile copies src to dst, used as fallback when rename fails cross-device +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/sandbox/v2/watcher.go b/sandbox/v2/watcher.go index e0c8315f..32c89af3 100644 --- a/sandbox/v2/watcher.go +++ b/sandbox/v2/watcher.go @@ -34,14 +34,53 @@ func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert { Target: "box:" + b.id, Message: fmt.Sprintf("status %s → %s", old, status), }) + if status == "running" && old != "running" { + b.touch() + alerts = append(alerts, monitor.Alert{ + Level: monitor.Trace, + Target: "box:" + b.id, + Message: fmt.Sprintf("touch on resume, lastCall reset to %s", + time.UnixMilli(b.lastCall.Load()).Format(time.RFC3339)), + }) + } } if status != "running" { return true } + // maxLifetime: independent of idle — prevents indefinitely running containers + if b.policy == LongRunning { + if lifetime := b.maxLifetime(); lifetime > 0 { + age := time.Since(b.createdAt) + if age > lifetime { + alerts = append(alerts, monitor.Alert{ + Level: monitor.Warn, + Target: "box:" + b.id, + Message: fmt.Sprintf("lifetime expired (%s), removing", lifetime), + Action: func(ctx context.Context) { mgr.Remove(ctx, b.id) }, + }) + } else { + alerts = append(alerts, monitor.Alert{ + Level: monitor.Trace, + Target: "box:" + b.id, + Message: fmt.Sprintf("lifetime remaining %s (max=%s)", + (lifetime - age).Round(time.Second), lifetime), + }) + } + } + } + idle := time.Since(b.idleSince()) timeout := b.idleTimeout() + + alerts = append(alerts, monitor.Alert{ + Level: monitor.Trace, + Target: "box:" + b.id, + Message: fmt.Sprintf("heartbeat status=%s policy=%s idle=%s timeout=%s", + status, b.policy, idle.Round(time.Second), timeout), + }) + if timeout <= 0 || idle <= timeout { return true } @@ -68,19 +107,6 @@ func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert { }) } - if b.policy == LongRunning { - if lifetime := b.maxLifetime(); lifetime > 0 && time.Since(b.createdAt) > lifetime { - alerts = append(alerts, monitor.Alert{ - Level: monitor.Warn, - Target: "box:" + b.id, - Message: fmt.Sprintf("lifetime expired (%s), removing", lifetime), - Action: func(ctx context.Context) { - mgr.Remove(ctx, b.id) - }, - }) - } - } - return true }) return alerts