From de114ade15ee29c04029416477a7d8debbc5128f Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 00:56:30 +0800 Subject: [PATCH 1/9] feat(docker): add relay daemon port mapping for container communication - Introduced a relay daemon port (2099/tcp) to facilitate communication between the host Tai server and containers. - Updated port bindings to ensure the relay port is always mapped to the host IP for improved connectivity. Made-with: Cursor --- tai/runtime/docker_core.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tai/runtime/docker_core.go b/tai/runtime/docker_core.go index 055387e2..26efe21d 100644 --- a/tai/runtime/docker_core.go +++ b/tai/runtime/docker_core.go @@ -52,6 +52,12 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts }} } + // tai relay daemon port — always mapped so the host Tai server can + // forward arbitrary container ports through the relay. + relayPort := nat.Port("2099/tcp") + exposedPorts[relayPort] = struct{}{} + portBindings[relayPort] = []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}} + if opts.VNC { hostCfg.CapAdd = append(hostCfg.CapAdd, "SYS_ADMIN") shmSize := opts.Memory / 4 From b39397ded03bd8dc6b6aeb18598adcac5c020aa8 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 10:18:55 +0800 Subject: [PATCH 2/9] feat(monitor): integrate monitor service start and stop in load and unload processes - Added functionality to start the monitor service during the loading process, ensuring that watchers are registered. - Implemented the stopping of the monitor service before unloading other services to allow for event handling. - Updated middleware to utilize access logging for improved request tracking. Made-with: Cursor --- engine/load.go | 12 + monitor/README.md | 153 +++++++++++ monitor/logger.go | 60 +++++ monitor/service.go | 250 ++++++++++++++++++ monitor/service_test.go | 507 +++++++++++++++++++++++++++++++++++++ monitor/types.go | 55 ++++ service/log/access.go | 96 +++++++ service/log/access_test.go | 214 ++++++++++++++++ service/middleware.go | 3 +- service/service.go | 3 + 10 files changed, 1352 insertions(+), 1 deletion(-) create mode 100644 monitor/README.md create mode 100644 monitor/logger.go create mode 100644 monitor/service.go create mode 100644 monitor/service_test.go create mode 100644 monitor/types.go create mode 100644 service/log/access.go create mode 100644 service/log/access_test.go diff --git a/engine/load.go b/engine/load.go index ac02803b..646887dd 100644 --- a/engine/load.go +++ b/engine/load.go @@ -34,6 +34,7 @@ import ( "github.com/yaoapp/yao/messenger" "github.com/yaoapp/yao/moapi" "github.com/yaoapp/yao/model" + "github.com/yaoapp/yao/monitor" "github.com/yaoapp/yao/openapi" "github.com/yaoapp/yao/pack" "github.com/yaoapp/yao/pipe" @@ -242,6 +243,14 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string warnings = append(warnings, Warning{Widget: "Event", Error: err}) } + // Start Monitor Service (watchers registered via init()) + err = loadStep("Monitor", func() error { + return monitor.Start(context.Background()) + }, callback) + if err != nil { + warnings = append(warnings, Warning{Widget: "Monitor", Error: err}) + } + // Load Uploaders err = loadStep("Uploader", func() error { return attachment.Load(cfg) @@ -455,6 +464,9 @@ func Unload() (err error) { } } + // Stop Monitor Service (before event, so watchers can still push events) + monitor.Stop() + // Stop Event Service (before runtime, so in-flight handlers can still use V8) event.Stop(context.Background()) diff --git a/monitor/README.md b/monitor/README.md new file mode 100644 index 00000000..5908b71a --- /dev/null +++ b/monitor/README.md @@ -0,0 +1,153 @@ +# Yao Monitor + +A process-level inspection service for Yao. Monitor schedules periodic health checks (watchers) and records anomalies. It knows nothing about business logic — the business layer defines what to check, how to judge, and what action to take. + +## Quick Start + +### 1. Implement a Watcher + +```go +package sandbox + +import ( + "context" + "time" + + "github.com/yaoapp/yao/monitor" +) + +type sandboxWatcher struct{} + +func (w *sandboxWatcher) Name() string { return "sandbox" } +func (w *sandboxWatcher) Interval() time.Duration { return 30 * time.Second } + +func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert { + // Inspect containers, compare states, detect idle timeouts, etc. + // Return an empty slice if everything is normal. + return nil +} +``` + +### 2. Register via init() + +```go +func init() { + monitor.Register(&sandboxWatcher{}) +} +``` + +Registration happens before `monitor.Start()` is called. The watcher will be picked up automatically when the engine boots. + +### 3. That's It + +The engine calls `monitor.Start()` / `monitor.Stop()` during load/unload. Your watcher's `Check()` will be called at the interval you specified, in its own goroutine. + +## Alert Levels + +| Level | Constant | Use Case | +|-------|----------|----------| +| Trace | `monitor.Trace` | Heartbeat, periodic status sync, routine checks | +| Info | `monitor.Info` | Notable events: state changes, service registrations | +| Warn | `monitor.Warn` | Needs attention: idle timeout, degraded state | +| Error | `monitor.Error` | Needs immediate action: crash, unreachable | + +Which level to use is entirely up to the business watcher — the monitor just records what it's told. + +All alert levels are delivered to subscribers via `Subscribe()`. + +### Log Level by Mode + +The minimum level written to `monitor.log` depends on Yao's run mode (`YAO_ENV`): + +| Mode | Min Level | Effect | +|------|-----------|--------| +| `production` | Info | Trace alerts are **not** written to the log file | +| `development` | Trace | **Everything** is written | + +This keeps production logs lean while giving full visibility during development. + +## Alert Actions + +An alert can carry an `Action` — a function the monitor executes synchronously within the tick: + +```go +monitor.Alert{ + Level: monitor.Warn, + Target: "box:abc123", + Message: "idle timeout exceeded, stopping", + Action: func(ctx context.Context) { box.Stop(ctx) }, +} +``` + +- Actions run synchronously in the watcher's goroutine. +- A panicking action is recovered and logged; subsequent alerts in the same tick continue. +- A long-running action blocks the next tick of *this* watcher only, not others. + +## API + +```go +// Register a watcher (call before Start, typically in init). +monitor.Register(w Watcher) + +// Start the monitor (called by engine). +monitor.Start(ctx context.Context) error + +// Stop the monitor (called by engine). +monitor.Stop() error + +// Subscribe to alert notifications. Returns a subscription ID. +// Non-blocking: full channels are skipped. +monitor.Subscribe(ch chan<- *monitor.Alert) string + +// Unsubscribe by ID. +monitor.Unsubscribe(id string) + +// Health returns runtime status of the monitor and all watchers. +monitor.Health() HealthStatus +``` + +## Health Check + +```go +status := monitor.Health() +// status.Running — is the monitor running? +// status.Watchers — per-watcher stats: +// .Name — watcher name +// .Interval — check frequency +// .LastTick — when the last tick completed +// .LastAlerts — alert count from the last tick +// .TotalTicks — total ticks since start +// .Panics — total panics caught +``` + +A watcher is considered healthy if `LastTick` is within `Interval × 3` of the current time. + +## Logging + +Monitor writes to `logs/monitor.log` (independent from `application.log`): + +- **Lifecycle events** (Info): monitor started/stopped, watcher started/stopped +- **Warn/Error alerts**: always written with watcher name, target, and message +- **Info alerts**: written in both production and development +- **Trace alerts**: written only in development mode (skipped in production) +- **Panics**: always written at Error level + +Log rotation uses lumberjack (50 MB, 3 backups, 7 days). Format follows `YAO_LOG_MODE` (TEXT or JSON). + +## Panic Safety + +- If `Check()` panics, the watcher recovers and continues on the next tick. +- If `Action()` panics, the watcher recovers and processes remaining alerts. +- Panic counts are tracked in `Health().Watchers[].Panics`. + +## File Structure + +``` +monitor/ +├── DESIGN.md — Architecture and design decisions +├── README.md — This file +├── types.go — Level, Alert, Watcher interface +├── logger.go — Independent slog.Logger → monitor.log +├── service.go — Register, Start, Stop, Subscribe, Health +└── service_test.go +``` diff --git a/monitor/logger.go b/monitor/logger.go new file mode 100644 index 00000000..07c126e0 --- /dev/null +++ b/monitor/logger.go @@ -0,0 +1,60 @@ +package monitor + +import ( + "log/slog" + "os" + "path/filepath" + + "gopkg.in/natefinch/lumberjack.v2" +) + +const slogLevelTrace = slog.Level(-8) + +var logger *slog.Logger + +// initLogger creates the monitor logger. +// appMode controls the minimum log level: +// - "production" → Info (Trace alerts are not written) +// - "development" → Trace (everything is written) +func initLogger(root string, logMode string, appMode string) { + logDir := filepath.Join(root, "logs") + if _, err := os.Stat(logDir); os.IsNotExist(err) { + os.MkdirAll(logDir, 0755) + } + + w := &lumberjack.Logger{ + Filename: filepath.Join(logDir, "monitor.log"), + MaxSize: 50, + MaxBackups: 3, + MaxAge: 7, + LocalTime: true, + } + + minLevel := slog.LevelInfo + if appMode == "development" { + minLevel = slogLevelTrace + } + + opts := &slog.HandlerOptions{Level: minLevel} + var handler slog.Handler + if logMode == "JSON" { + handler = slog.NewJSONHandler(w, opts) + } else { + handler = slog.NewTextHandler(w, opts) + } + + logger = slog.New(handler) +} + +func levelToSlog(l Level) slog.Level { + switch l { + case Trace: + return slogLevelTrace + case Warn: + return slog.LevelWarn + case Error: + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/monitor/service.go b/monitor/service.go new file mode 100644 index 00000000..0243031c --- /dev/null +++ b/monitor/service.go @@ -0,0 +1,250 @@ +package monitor + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/yaoapp/yao/config" +) + +var svc = &monitorService{ + watchers: make(map[string]*watcherEntry), + subs: make(map[string]chan<- *Alert), +} + +type watcherEntry struct { + watcher Watcher + cancel context.CancelFunc + lastTick atomic.Int64 // unix timestamp of last tick completion + lastAlerts atomic.Int64 // alert count from last tick + totalTicks atomic.Int64 // total ticks since start + panics atomic.Int64 // total panics caught +} + +type monitorService struct { + mu sync.Mutex + watchers map[string]*watcherEntry + subs map[string]chan<- *Alert + subSeq int + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + started bool +} + +// Register adds a watcher. Call before Start (typically in init). +// Registering a watcher with the same name replaces the previous one. +func Register(w Watcher) { + svc.mu.Lock() + defer svc.mu.Unlock() + + name := w.Name() + if old, ok := svc.watchers[name]; ok && old.cancel != nil { + old.cancel() + } + svc.watchers[name] = &watcherEntry{watcher: w} + + if svc.started { + svc.startWatcher(svc.watchers[name]) + } +} + +// Start initializes the logger and launches a goroutine per registered watcher. +func Start(ctx context.Context) error { + svc.mu.Lock() + defer svc.mu.Unlock() + + if svc.started { + return fmt.Errorf("monitor: already started") + } + + initLogger(config.Conf.Root, config.Conf.LogMode, config.Conf.Mode) + + svc.ctx, svc.cancel = context.WithCancel(ctx) + for _, entry := range svc.watchers { + svc.startWatcher(entry) + } + svc.started = true + + if logger != nil { + logger.Info("monitor started", "watchers", len(svc.watchers)) + } + return nil +} + +// Stop cancels all watcher goroutines and waits for them to finish. +func Stop() error { + svc.mu.Lock() + if !svc.started { + svc.mu.Unlock() + return nil + } + svc.cancel() + svc.started = false + svc.mu.Unlock() + + svc.wg.Wait() + + if logger != nil { + logger.Info("monitor stopped") + } + return nil +} + +// Subscribe registers a channel to receive alert notifications. +// Returns a subscription ID for unsubscribing. +// Non-blocking: if the channel is full, alerts are dropped for that subscriber. +func Subscribe(ch chan<- *Alert) string { + svc.mu.Lock() + defer svc.mu.Unlock() + + svc.subSeq++ + id := fmt.Sprintf("sub-%d", svc.subSeq) + svc.subs[id] = ch + return id +} + +// Unsubscribe removes a subscription by ID. +func Unsubscribe(id string) { + svc.mu.Lock() + defer svc.mu.Unlock() + delete(svc.subs, id) +} + +// WatcherHealth describes the runtime status of a single watcher. +type WatcherHealth struct { + Name string `json:"name"` + Interval time.Duration `json:"interval"` + LastTick time.Time `json:"last_tick"` // zero if never ticked + LastAlerts int64 `json:"last_alerts"` // alert count from most recent tick + TotalTicks int64 `json:"total_ticks"` + Panics int64 `json:"panics"` +} + +// HealthStatus describes the overall monitor health. +type HealthStatus struct { + Running bool `json:"running"` + Watchers []WatcherHealth `json:"watchers"` +} + +// Health returns the current health status of the monitor service. +func Health() HealthStatus { + svc.mu.Lock() + defer svc.mu.Unlock() + + status := HealthStatus{Running: svc.started} + for _, entry := range svc.watchers { + wh := WatcherHealth{ + Name: entry.watcher.Name(), + Interval: entry.watcher.Interval(), + LastAlerts: entry.lastAlerts.Load(), + TotalTicks: entry.totalTicks.Load(), + Panics: entry.panics.Load(), + } + if ts := entry.lastTick.Load(); ts > 0 { + wh.LastTick = time.Unix(ts, 0) + } + status.Watchers = append(status.Watchers, wh) + } + return status +} + +func (s *monitorService) startWatcher(entry *watcherEntry) { + ctx, cancel := context.WithCancel(s.ctx) + entry.cancel = cancel + s.wg.Add(1) + go s.runLoop(ctx, entry) +} + +func (s *monitorService) runLoop(ctx context.Context, entry *watcherEntry) { + defer s.wg.Done() + + w := entry.watcher + name := w.Name() + interval := w.Interval() + + if logger != nil { + logger.Info("watcher started", "watcher", name, "interval", interval) + } + + // Run first check immediately, then on ticker. + s.tick(ctx, entry) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.tick(ctx, entry) + case <-ctx.Done(): + if logger != nil { + logger.Info("watcher stopped", "watcher", name) + } + return + } + } +} + +func (s *monitorService) tick(ctx context.Context, entry *watcherEntry) { + name := entry.watcher.Name() + + defer func() { + if r := recover(); r != nil { + entry.panics.Add(1) + if logger != nil { + logger.Error("watcher panic", "watcher", name, "recover", fmt.Sprintf("%v", r)) + } + } + entry.totalTicks.Add(1) + entry.lastTick.Store(time.Now().Unix()) + }() + + alerts := entry.watcher.Check(ctx) + entry.lastAlerts.Store(int64(len(alerts))) + + for i := range alerts { + a := &alerts[i] + a.Watcher = name + + // Log level filtering is handled by slog handler: + // production → Info and above (Trace skipped) + // development → Trace and above (everything) + if logger != nil { + logger.Log(ctx, levelToSlog(a.Level), a.Message, + "watcher", name, "target", a.Target) + } + + if a.Action != nil { + s.execAction(ctx, name, a) + } + + s.notify(a) + } +} + +func (s *monitorService) execAction(ctx context.Context, watcherName string, a *Alert) { + defer func() { + if r := recover(); r != nil { + if logger != nil { + logger.Error("action panic", "watcher", watcherName, "target", a.Target, "recover", fmt.Sprintf("%v", r)) + } + } + }() + a.Action(ctx) +} + +func (s *monitorService) notify(a *Alert) { + s.mu.Lock() + defer s.mu.Unlock() + + for _, ch := range s.subs { + select { + case ch <- a: + default: + } + } +} diff --git a/monitor/service_test.go b/monitor/service_test.go new file mode 100644 index 00000000..1aab3b06 --- /dev/null +++ b/monitor/service_test.go @@ -0,0 +1,507 @@ +package monitor + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// resetService resets the global service for test isolation. +func resetService() { + svc.mu.Lock() + defer svc.mu.Unlock() + + if svc.started && svc.cancel != nil { + svc.cancel() + svc.wg.Wait() + } + + svc.watchers = make(map[string]*watcherEntry) + svc.subs = make(map[string]chan<- *Alert) + svc.subSeq = 0 + svc.started = false + svc.ctx = nil + svc.cancel = nil + + // Use a discard logger for tests + logger = nil +} + +// testWatcher is a simple watcher for testing. +type testWatcher struct { + name string + interval time.Duration + checkFn func(ctx context.Context) []Alert +} + +func (w *testWatcher) Name() string { return w.name } +func (w *testWatcher) Interval() time.Duration { return w.interval } +func (w *testWatcher) Check(ctx context.Context) []Alert { + if w.checkFn != nil { + return w.checkFn(ctx) + } + return nil +} + +func TestRegisterAndStart(t *testing.T) { + resetService() + defer resetService() + + var count atomic.Int32 + Register(&testWatcher{ + name: "test-basic", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + count.Add(1) + return nil + }, + }) + + err := Start(context.Background()) + if err != nil { + t.Fatalf("Start: %v", err) + } + + // Wait for a few ticks (first immediate + ticker) + time.Sleep(200 * time.Millisecond) + + if err := Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + + c := count.Load() + if c < 2 { + t.Errorf("expected at least 2 checks (immediate + ticker), got %d", c) + } +} + +func TestDoubleStartError(t *testing.T) { + resetService() + defer resetService() + + Register(&testWatcher{name: "dummy", interval: time.Second}) + + if err := Start(context.Background()); err != nil { + t.Fatal(err) + } + + if err := Start(context.Background()); err == nil { + t.Error("expected error on double Start") + } + + Stop() +} + +func TestStopWithoutStart(t *testing.T) { + resetService() + if err := Stop(); err != nil { + t.Errorf("Stop without Start should not error: %v", err) + } +} + +func TestAlertWatcherName(t *testing.T) { + resetService() + defer resetService() + + var got string + var mu sync.Mutex + + Register(&testWatcher{ + name: "namer", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + mu.Lock() + defer mu.Unlock() + if got != "" { + return nil + } + return []Alert{{ + Level: Info, + Target: "test:1", + Message: "hello", + }} + }, + }) + + ch := make(chan *Alert, 8) + subID := Subscribe(ch) + defer Unsubscribe(subID) + + Start(context.Background()) + defer Stop() + + select { + case a := <-ch: + if a.Watcher != "namer" { + t.Errorf("expected Watcher='namer', got %q", a.Watcher) + } + mu.Lock() + got = a.Watcher + mu.Unlock() + case <-time.After(time.Second): + t.Fatal("timeout waiting for alert") + } +} + +func TestAlertAction(t *testing.T) { + resetService() + defer resetService() + + var acted atomic.Bool + + Register(&testWatcher{ + name: "actor", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + if acted.Load() { + return nil + } + return []Alert{{ + Level: Warn, + Target: "test:action", + Message: "do something", + Action: func(ctx context.Context) { + acted.Store(true) + }, + }} + }, + }) + + Start(context.Background()) + defer Stop() + + time.Sleep(200 * time.Millisecond) + + if !acted.Load() { + t.Error("action was not executed") + } +} + +func TestPanicRecovery_Check(t *testing.T) { + resetService() + defer resetService() + + var count atomic.Int32 + + Register(&testWatcher{ + name: "panicker", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + n := count.Add(1) + if n == 1 { + panic("boom") + } + return nil + }, + }) + + Start(context.Background()) + time.Sleep(200 * time.Millisecond) + Stop() + + c := count.Load() + if c < 2 { + t.Errorf("expected watcher to continue after panic, got %d checks", c) + } +} + +func TestPanicRecovery_Action(t *testing.T) { + resetService() + defer resetService() + + var postPanic atomic.Bool + + Register(&testWatcher{ + name: "action-panicker", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + return []Alert{ + { + Level: Error, + Target: "test:panic-action", + Message: "will panic", + Action: func(ctx context.Context) { + if !postPanic.Load() { + panic("action boom") + } + }, + }, + } + }, + }) + + Start(context.Background()) + time.Sleep(150 * time.Millisecond) + postPanic.Store(true) + time.Sleep(100 * time.Millisecond) + Stop() +} + +func TestSubscribeUnsubscribe(t *testing.T) { + resetService() + defer resetService() + + ch := make(chan *Alert, 16) + id := Subscribe(ch) + + Register(&testWatcher{ + name: "sub-test", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + return []Alert{{Level: Info, Target: "t", Message: "msg"}} + }, + }) + + Start(context.Background()) + time.Sleep(100 * time.Millisecond) + Unsubscribe(id) + time.Sleep(100 * time.Millisecond) + Stop() + + // Drain and count + close(ch) + count := 0 + for range ch { + count++ + } + if count == 0 { + t.Error("expected at least one alert before unsubscribe") + } +} + +func TestSubscribeFullChanDrops(t *testing.T) { + resetService() + defer resetService() + + ch := make(chan *Alert, 1) // tiny buffer + Subscribe(ch) + + Register(&testWatcher{ + name: "flood", + interval: 10 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + return []Alert{{Level: Info, Target: "t", Message: "flood"}} + }, + }) + + Start(context.Background()) + time.Sleep(200 * time.Millisecond) + Stop() + // No deadlock = pass +} + +func TestRegisterOverwrite(t *testing.T) { + resetService() + defer resetService() + + var first, second atomic.Int32 + + Register(&testWatcher{ + name: "dup", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + first.Add(1) + return nil + }, + }) + + Register(&testWatcher{ + name: "dup", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + second.Add(1) + return nil + }, + }) + + Start(context.Background()) + time.Sleep(200 * time.Millisecond) + Stop() + + if first.Load() > 0 { + t.Error("first watcher should have been replaced") + } + if second.Load() == 0 { + t.Error("second watcher should have run") + } +} + +func TestRegisterAfterStart(t *testing.T) { + resetService() + defer resetService() + + Start(context.Background()) + defer Stop() + + var count atomic.Int32 + Register(&testWatcher{ + name: "late", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + count.Add(1) + return nil + }, + }) + + time.Sleep(200 * time.Millisecond) + + if count.Load() == 0 { + t.Error("watcher registered after Start should still run") + } +} + +func TestLevelString(t *testing.T) { + tests := []struct { + level Level + want string + }{ + {Trace, "trace"}, + {Info, "info"}, + {Warn, "warn"}, + {Error, "error"}, + {Level(99), "unknown"}, + } + for _, tt := range tests { + got := tt.level.String() + if got != tt.want { + t.Errorf("Level(%d).String() = %q, want %q", tt.level, got, tt.want) + } + } +} + +func TestContextCancelledDuringCheck(t *testing.T) { + resetService() + defer resetService() + + var checkDone atomic.Bool + + Register(&testWatcher{ + name: "slow", + interval: time.Hour, // only immediate check runs + checkFn: func(ctx context.Context) []Alert { + select { + case <-ctx.Done(): + checkDone.Store(true) + case <-time.After(2 * time.Second): + } + return nil + }, + }) + + Start(context.Background()) + + // Give the immediate check time to start + time.Sleep(50 * time.Millisecond) + + // Stop should cancel the context + done := make(chan struct{}) + go func() { + Stop() + close(done) + }() + + select { + case <-done: + if !checkDone.Load() { + // The check might have not started yet, that's ok + fmt.Println("note: check may not have started before stop") + } + case <-time.After(3 * time.Second): + t.Fatal("Stop timed out — watcher may not respect context cancellation") + } +} + +func TestHealth_NotStarted(t *testing.T) { + resetService() + defer resetService() + + Register(&testWatcher{name: "idle", interval: time.Second}) + + h := Health() + if h.Running { + t.Error("expected Running=false before Start") + } + if len(h.Watchers) != 1 { + t.Errorf("expected 1 watcher, got %d", len(h.Watchers)) + } + if h.Watchers[0].TotalTicks != 0 { + t.Errorf("expected 0 ticks before Start, got %d", h.Watchers[0].TotalTicks) + } +} + +func TestHealth_Running(t *testing.T) { + resetService() + defer resetService() + + Register(&testWatcher{ + name: "healthy", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + return []Alert{{Level: Info, Target: "t:1", Message: "ok"}} + }, + }) + + Start(context.Background()) + time.Sleep(200 * time.Millisecond) + + h := Health() + if !h.Running { + t.Error("expected Running=true") + } + if len(h.Watchers) != 1 { + t.Fatalf("expected 1 watcher, got %d", len(h.Watchers)) + } + + wh := h.Watchers[0] + if wh.Name != "healthy" { + t.Errorf("expected name 'healthy', got %q", wh.Name) + } + if wh.TotalTicks < 2 { + t.Errorf("expected at least 2 ticks, got %d", wh.TotalTicks) + } + if wh.LastTick.IsZero() { + t.Error("expected non-zero LastTick") + } + if wh.LastAlerts != 1 { + t.Errorf("expected 1 alert per tick, got %d", wh.LastAlerts) + } + if wh.Panics != 0 { + t.Errorf("expected 0 panics, got %d", wh.Panics) + } + + Stop() +} + +func TestHealth_PanicCount(t *testing.T) { + resetService() + defer resetService() + + var n atomic.Int32 + Register(&testWatcher{ + name: "crasher", + interval: 50 * time.Millisecond, + checkFn: func(ctx context.Context) []Alert { + if n.Add(1) <= 2 { + panic("crash") + } + return nil + }, + }) + + Start(context.Background()) + time.Sleep(250 * time.Millisecond) + + h := Health() + wh := h.Watchers[0] + if wh.Panics < 2 { + t.Errorf("expected at least 2 panics, got %d", wh.Panics) + } + if wh.TotalTicks <= wh.Panics { + t.Errorf("expected some successful ticks after panics: total=%d, panics=%d", wh.TotalTicks, wh.Panics) + } + + Stop() +} diff --git a/monitor/types.go b/monitor/types.go new file mode 100644 index 00000000..faa62f00 --- /dev/null +++ b/monitor/types.go @@ -0,0 +1,55 @@ +package monitor + +import ( + "context" + "time" +) + +// Level represents alert severity. +type Level int + +const ( + Trace Level = iota // Heartbeat, periodic status sync — not logged + Info // Notable events: state changes, registrations + Warn // Needs attention: idle timeout, degraded state + Error // Needs immediate action: crash, unreachable +) + +func (l Level) String() string { + switch l { + case Trace: + return "trace" + case Info: + return "info" + case Warn: + return "warn" + case Error: + return "error" + default: + return "unknown" + } +} + +// Alert represents a single finding from a watcher check. +type Alert struct { + Watcher string // Source watcher name (set by monitor) + Level Level // Severity + Target string // Target identifier, e.g. "box:abc123", "robot:member-456" + Message string // Human-readable description + Action func(ctx context.Context) // Business-layer action; nil means notification only +} + +// Watcher is the interface that business modules implement and register +// with the monitor service. +type Watcher interface { + // Name returns a globally unique watcher name, used for logging and dedup. + Name() string + + // Interval returns the check frequency. + Interval() time.Duration + + // Check performs a single inspection and returns any alerts found. + // An empty slice means everything is normal. + // ctx is cancelled when the monitor stops. + Check(ctx context.Context) []Alert +} diff --git a/service/log/access.go b/service/log/access.go new file mode 100644 index 00000000..495424af --- /dev/null +++ b/service/log/access.go @@ -0,0 +1,96 @@ +package log + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/natefinch/lumberjack.v2" +) + +var ( + accessWriter *lumberjack.Logger + accessErrorWriter *lumberjack.Logger +) + +// InitAccessLog initializes access log and access-error log writers. +// Must be called before any HTTP request is served. +func InitAccessLog(root string) { + logDir := filepath.Join(root, "logs") + if _, err := os.Stat(logDir); os.IsNotExist(err) { + os.MkdirAll(logDir, 0755) + } + + accessWriter = &lumberjack.Logger{ + Filename: filepath.Join(logDir, "access.log"), + MaxSize: 100, + MaxBackups: 5, + MaxAge: 30, + LocalTime: true, + } + accessErrorWriter = &lumberjack.Logger{ + Filename: filepath.Join(logDir, "access-error.log"), + MaxSize: 50, + MaxBackups: 5, + MaxAge: 30, + LocalTime: true, + } +} + +// AccessLog returns a gin middleware that writes NGINX Combined Log Format +// to access.log (all requests) and access-error.log (4xx/5xx only). +func AccessLog() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + if accessWriter == nil { + return + } + + status := c.Writer.Status() + size := c.Writer.Size() + if size < 0 { + size = 0 + } + + line := fmt.Sprintf("%s - %s [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"\n", + c.ClientIP(), + remoteUser(c), + time.Now().Format("02/Jan/2006:15:04:05 -0700"), + c.Request.Method, + c.Request.RequestURI, + c.Request.Proto, + status, + size, + dash(c.Request.Referer()), + dash(c.Request.UserAgent()), + ) + + accessWriter.Write([]byte(line)) + if status >= 400 { + accessErrorWriter.Write([]byte(line)) + } + } +} + +// remoteUser extracts a user identifier from the gin context. +// Tries __username (JWT), __user_id (OAuth), __sid (SUI session) in order. +func remoteUser(c *gin.Context) string { + for _, key := range []string{"__username", "__user_id", "__sid"} { + if v, ok := c.Get(key); ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "-" +} + +func dash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/service/log/access_test.go b/service/log/access_test.go new file mode 100644 index 00000000..bc132728 --- /dev/null +++ b/service/log/access_test.go @@ -0,0 +1,214 @@ +package log + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func setupTestLog(t *testing.T) (string, func()) { + t.Helper() + dir := t.TempDir() + InitAccessLog(dir) + return dir, func() { + if accessWriter != nil { + accessWriter.Close() + } + if accessErrorWriter != nil { + accessErrorWriter.Close() + } + accessWriter = nil + accessErrorWriter = nil + } +} + +// NGINX Combined: $remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent" +var nginxCombinedRe = regexp.MustCompile( + `^(\S+) - (\S+) \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] "(\S+) (\S+) (\S+)" (\d{3}) (\d+) "(.*)" "(.*)"$`, +) + +func TestAccessLog_NginxFormat(t *testing.T) { + dir, cleanup := setupTestLog(t) + defer cleanup() + + router := gin.New() + router.Use(AccessLog()) + router.GET("/api/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("User-Agent", "TestAgent/1.0") + req.Header.Set("Referer", "https://example.com") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + data, err := os.ReadFile(filepath.Join(dir, "logs", "access.log")) + if err != nil { + t.Fatalf("read access.log: %v", err) + } + + line := strings.TrimSpace(string(data)) + if !nginxCombinedRe.MatchString(line) { + t.Errorf("access.log line does not match NGINX Combined format:\n%s", line) + } + + if !strings.Contains(line, `"GET /api/test HTTP/1.1"`) { + t.Errorf("expected request line in log, got: %s", line) + } + if !strings.Contains(line, `"https://example.com"`) { + t.Errorf("expected referer in log, got: %s", line) + } + if !strings.Contains(line, `"TestAgent/1.0"`) { + t.Errorf("expected user-agent in log, got: %s", line) + } + + // access-error.log should be empty for 200 + errData, err := os.ReadFile(filepath.Join(dir, "logs", "access-error.log")) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read access-error.log: %v", err) + } + if len(strings.TrimSpace(string(errData))) > 0 { + t.Errorf("access-error.log should be empty for 200, got: %s", string(errData)) + } +} + +func TestAccessLog_ErrorDoubleWrite(t *testing.T) { + dir, cleanup := setupTestLog(t) + defer cleanup() + + router := gin.New() + router.Use(AccessLog()) + router.GET("/api/fail", func(c *gin.Context) { + c.String(http.StatusInternalServerError, "error") + }) + router.GET("/api/notfound", func(c *gin.Context) { + c.String(http.StatusNotFound, "not found") + }) + + // 500 request + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/fail", nil)) + + // 404 request + w = httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/notfound", nil)) + + // 200 request (should NOT appear in error log) + router.GET("/api/ok", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + w = httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", "/api/ok", nil)) + + accessData, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log")) + accessLines := nonEmptyLines(string(accessData)) + if len(accessLines) != 3 { + t.Fatalf("access.log: expected 3 lines, got %d:\n%s", len(accessLines), string(accessData)) + } + + errData, _ := os.ReadFile(filepath.Join(dir, "logs", "access-error.log")) + errLines := nonEmptyLines(string(errData)) + if len(errLines) != 2 { + t.Fatalf("access-error.log: expected 2 lines (500+404), got %d:\n%s", len(errLines), string(errData)) + } + + if !strings.Contains(errLines[0], "500") { + t.Errorf("first error line should contain 500: %s", errLines[0]) + } + if !strings.Contains(errLines[1], "404") { + t.Errorf("second error line should contain 404: %s", errLines[1]) + } +} + +func TestAccessLog_RemoteUser(t *testing.T) { + dir, cleanup := setupTestLog(t) + defer cleanup() + + router := gin.New() + router.Use(AccessLog()) + router.GET("/api/user", func(c *gin.Context) { + c.Set("__username", "alice") + c.String(http.StatusOK, "ok") + }) + router.GET("/api/userid", func(c *gin.Context) { + c.Set("__user_id", "uid-123") + c.String(http.StatusOK, "ok") + }) + router.GET("/api/anon", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + for _, path := range []string{"/api/user", "/api/userid", "/api/anon"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + } + + data, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log")) + lines := nonEmptyLines(string(data)) + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + + // Note: AccessLog middleware runs c.Next() first, then reads context. + // The user keys are set inside the handler which runs during c.Next(), + // so they should be available when the log line is written. + if !strings.Contains(lines[0], " alice ") { + t.Errorf("line 1 should have user 'alice': %s", lines[0]) + } + if !strings.Contains(lines[1], " uid-123 ") { + t.Errorf("line 2 should have user 'uid-123': %s", lines[1]) + } + if !strings.Contains(lines[2], " - ") { + t.Errorf("line 3 should have '-' for anonymous: %s", lines[2]) + } +} + +func TestAccessLog_DashForEmpty(t *testing.T) { + dir, cleanup := setupTestLog(t) + defer cleanup() + + router := gin.New() + router.Use(AccessLog()) + router.GET("/api/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req := httptest.NewRequest("GET", "/api/test", nil) + // No Referer, no User-Agent + req.Header.Del("User-Agent") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + data, _ := os.ReadFile(filepath.Join(dir, "logs", "access.log")) + line := strings.TrimSpace(string(data)) + + // Should end with "-" "-" for empty referer and user-agent + if !strings.HasSuffix(line, `"-" "-"`) { + t.Errorf("expected dash for empty referer/ua, got: %s", line) + } +} + +func nonEmptyLines(s string) []string { + var result []string + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + result = append(result, line) + } + } + return result +} diff --git a/service/middleware.go b/service/middleware.go index 6589c92c..aa57e031 100644 --- a/service/middleware.go +++ b/service/middleware.go @@ -11,13 +11,14 @@ import ( "github.com/gin-gonic/gin" "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/openapi" + servicelog "github.com/yaoapp/yao/service/log" "github.com/yaoapp/yao/share" "github.com/yaoapp/yao/sui/api" ) // Middlewares the middlewares var Middlewares = []gin.HandlerFunc{ - gin.Logger(), + servicelog.AccessLog(), withStaticFileServer, } diff --git a/service/service.go b/service/service.go index 74c2bee2..c84994f3 100644 --- a/service/service.go +++ b/service/service.go @@ -9,6 +9,7 @@ import ( "github.com/yaoapp/gou/server/http" "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/openapi" + servicelog "github.com/yaoapp/yao/service/log" "github.com/yaoapp/yao/share" ) @@ -148,6 +149,8 @@ func Restart(svc *Service, cfg config.Config) error { } func prepare() error { + servicelog.InitAccessLog(config.Conf.Root) + err := share.SessionStart() if err != nil { return err From 6d5ae17d2fe8fb9dc29399d9c8108a707378dedf Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 10:49:47 +0800 Subject: [PATCH 3/9] feat(grpc): refactor BuildGRPCEnv to use port from Tai node and update tests - Modified BuildGRPCEnv to accept the Tai node's gRPC port directly, enhancing flexibility for different modes. - Updated test cases to reflect changes in gRPC address construction, ensuring accurate environment variable settings for local, direct, and tunnel modes. - Added a new test for handling unknown modes, improving test coverage and robustness. - Adjusted Docker configuration to ensure proper host resolution for gRPC communication. Made-with: Cursor --- sandbox/v2/grpc.go | 58 +++++++++++++++++--------------------- sandbox/v2/grpc_test.go | 41 +++++++++++++++++++++------ sandbox/v2/manager.go | 2 +- tai/runtime/docker_core.go | 3 +- 4 files changed, 61 insertions(+), 43 deletions(-) diff --git a/sandbox/v2/grpc.go b/sandbox/v2/grpc.go index e3d85399..9dc949ef 100644 --- a/sandbox/v2/grpc.go +++ b/sandbox/v2/grpc.go @@ -2,53 +2,47 @@ package sandbox import ( "fmt" - "net/url" - "strconv" "github.com/yaoapp/yao/config" ) -// BuildGRPCEnv builds the gRPC environment variables for a sandbox container -// based on the Tai node's mode and address from the registry. -// -// mode is the TaiNode.Mode ("local", "direct", "tunnel"). -// addr is the TaiNode.Addr (e.g. "tai://host:port" for direct mode). -// sandboxID is the container's sandbox identifier. -// -// The Yao gRPC port is read from config.Conf.GRPC.Port. -func BuildGRPCEnv(mode, addr, sandboxID string) map[string]string { - grpcPort := config.Conf.GRPC.Port - if grpcPort == 0 { - grpcPort = 9099 - } - portStr := strconv.Itoa(grpcPort) +const taiHost = "host.tai.internal" +// BuildGRPCEnv builds the gRPC environment variables for a sandbox container. +// +// All containers reach the host via "host.tai.internal" (injected by Tai at +// container creation). The port depends on the mode: +// +// - local: Yao gRPC port (Tai and Yao on the same machine) +// - tunnel/direct: Tai gRPC port (Tai Gateway forwards to Yao) +// +// taiGRPCPort is the Tai node's gRPC port from registration (Ports.GRPC). +func BuildGRPCEnv(mode string, taiGRPCPort int, sandboxID string) map[string]string { env := map[string]string{ "YAO_SANDBOX_ID": sandboxID, } switch mode { case "local": - env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) - - case "tunnel": - env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) - - case "direct": - u, err := url.Parse(addr) - if err != nil || u.Hostname() == "" { - env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) - return env + port := config.Conf.GRPC.Port + if port == 0 { + port = 9099 } - taiHost := u.Hostname() - taiPort := u.Port() - if taiPort == "" { - taiPort = "19100" + env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port) + + case "tunnel", "direct": + port := taiGRPCPort + if port == 0 { + port = 19100 } - env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort) + env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port) default: - env["YAO_GRPC_ADDR"] = fmt.Sprintf("host.docker.internal:%s", portStr) + port := config.Conf.GRPC.Port + if port == 0 { + port = 9099 + } + env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%d", taiHost, port) } return env } diff --git a/sandbox/v2/grpc_test.go b/sandbox/v2/grpc_test.go index 800a2cae..4da6f0f0 100644 --- a/sandbox/v2/grpc_test.go +++ b/sandbox/v2/grpc_test.go @@ -9,7 +9,7 @@ import ( func TestBuildGRPCEnvLocal(t *testing.T) { config.Conf.GRPC.Port = 9099 - env := sandbox.BuildGRPCEnv("local", "", "sb-001") + env := sandbox.BuildGRPCEnv("local", 19100, "sb-001") if env["YAO_SANDBOX_ID"] != "sb-001" { t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"]) @@ -17,25 +17,48 @@ func TestBuildGRPCEnvLocal(t *testing.T) { if _, ok := env["YAO_TOKEN"]; ok { t.Error("YAO_TOKEN should not be set by BuildGRPCEnv") } - if env["YAO_GRPC_ADDR"] != "host.docker.internal:9099" { - t.Errorf("YAO_GRPC_ADDR = %q, want host.docker.internal:9099", env["YAO_GRPC_ADDR"]) + want := "host.tai.internal:9099" + if env["YAO_GRPC_ADDR"] != want { + t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want) } } func TestBuildGRPCEnvDirect(t *testing.T) { config.Conf.GRPC.Port = 9099 - env := sandbox.BuildGRPCEnv("direct", "tai://gpu-server", "sb-002") + env := sandbox.BuildGRPCEnv("direct", 19100, "sb-002") - if env["YAO_GRPC_ADDR"] != "gpu-server:19100" { - t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:19100", env["YAO_GRPC_ADDR"]) + want := "host.tai.internal:19100" + if env["YAO_GRPC_ADDR"] != want { + t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want) + } +} + +func TestBuildGRPCEnvDirectDefaultPort(t *testing.T) { + config.Conf.GRPC.Port = 9099 + env := sandbox.BuildGRPCEnv("direct", 0, "sb-002") + + want := "host.tai.internal:19100" + if env["YAO_GRPC_ADDR"] != want { + t.Errorf("YAO_GRPC_ADDR = %q, want %q (default tai port)", env["YAO_GRPC_ADDR"], want) } } func TestBuildGRPCEnvTunnel(t *testing.T) { config.Conf.GRPC.Port = 9099 - env := sandbox.BuildGRPCEnv("tunnel", "tunnel://relay.example.com", "sb-003") + env := sandbox.BuildGRPCEnv("tunnel", 19200, "sb-003") - if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { - t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"]) + want := "host.tai.internal:19200" + if env["YAO_GRPC_ADDR"] != want { + t.Errorf("YAO_GRPC_ADDR = %q, want %q", env["YAO_GRPC_ADDR"], want) + } +} + +func TestBuildGRPCEnvUnknownMode(t *testing.T) { + config.Conf.GRPC.Port = 8888 + env := sandbox.BuildGRPCEnv("unknown", 19100, "sb-004") + + want := "host.tai.internal:8888" + if env["YAO_GRPC_ADDR"] != want { + t.Errorf("YAO_GRPC_ADDR = %q, want %q (fallback to yao port)", env["YAO_GRPC_ADDR"], want) } } diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index 4f0fe5aa..ff26c2a6 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -350,7 +350,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, nodeID, sandboxID st reg := registry.Global() if reg != nil { if snap, ok := reg.Get(nodeID); ok { - grpcEnv := BuildGRPCEnv(snap.Mode, snap.Addr, sandboxID) + grpcEnv := BuildGRPCEnv(snap.Mode, snap.Ports.GRPC, sandboxID) for k, v := range grpcEnv { env[k] = v } diff --git a/tai/runtime/docker_core.go b/tai/runtime/docker_core.go index 26efe21d..4394f7c7 100644 --- a/tai/runtime/docker_core.go +++ b/tai/runtime/docker_core.go @@ -31,7 +31,8 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts } hostCfg := &container.HostConfig{ - Binds: opts.Binds, + Binds: opts.Binds, + ExtraHosts: []string{"host.tai.internal:host-gateway"}, } if opts.Memory > 0 { From 3797091d374c2a9959db9c7d25a3d201fda90130 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 11:51:09 +0800 Subject: [PATCH 4/9] feat(sandbox): enhance lifecycle management and box status handling - Updated BuildIdentifier to include assistant ID in session identifiers for better uniqueness. - Implemented automatic starting of stopped boxes in resolveBox, improving recovery processes. - Introduced status management for boxes, allowing for accurate tracking of their state (running, exited, stopped). - Added idle timeout defaults based on lifecycle policy in BuildCreateOptions, enhancing configuration flexibility. - Refactored tests to validate new box status behavior and lifecycle management improvements. Made-with: Cursor --- agent/sandbox/v2/lifecycle.go | 22 ++++--- agent/sandbox/v2/options.go | 8 +++ sandbox/v2/box.go | 34 +++++++++- sandbox/v2/manager.go | 99 +++++++++++----------------- sandbox/v2/manager_lifecycle_test.go | 83 ++++++++++------------- sandbox/v2/types.go | 6 +- sandbox/v2/watcher.go | 87 ++++++++++++++++++++++++ 7 files changed, 219 insertions(+), 120 deletions(-) create mode 100644 sandbox/v2/watcher.go diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go index bad0b57c..3bbf0c8d 100644 --- a/agent/sandbox/v2/lifecycle.go +++ b/agent/sandbox/v2/lifecycle.go @@ -22,16 +22,9 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor return "" } - // Custom identifier from metadata takes precedence. - if metadata != nil { - if cid, ok := metadata["computer_id"].(string); ok && cid != "" { - return fmt.Sprintf("%s-%s.%s", ownerID, cid, workspaceID) - } - } - switch cfg.Lifecycle { case "session": - return fmt.Sprintf("%s-%s", ownerID, chatID) + return fmt.Sprintf("%s-%s-%s", ownerID, assistantID, chatID) case "longrunning", "persistent": return fmt.Sprintf("%s-%s.%s", ownerID, assistantID, workspaceID) default: @@ -184,8 +177,17 @@ func resolveBox( if identifier != "" { box, err := manager.Get(context.Background(), identifier) if err == nil && box != nil { - box.BindWorkplace(workspaceID) - return box, identifier, nil + if box.IsStopped() { + if startErr := manager.StartBox(context.Background(), identifier); startErr != nil { + log.Printf("[sandbox/v2] auto-start stopped box %s failed: %v, creating new", identifier, startErr) + } else { + box.BindWorkplace(workspaceID) + return box, identifier, nil + } + } else { + box.BindWorkplace(workspaceID) + return box, identifier, nil + } } } diff --git a/agent/sandbox/v2/options.go b/agent/sandbox/v2/options.go index 2ac6ce92..4be74d31 100644 --- a/agent/sandbox/v2/options.go +++ b/agent/sandbox/v2/options.go @@ -63,6 +63,14 @@ func BuildCreateOptions(cfg *types.SandboxConfig, identifier, ownerID, workspace } opts.IdleTimeout = d } + if opts.IdleTimeout == 0 { + switch opts.Policy { + case infra.Session: + opts.IdleTimeout = infra.DefaultSessionIdleTimeout + case infra.LongRunning: + opts.IdleTimeout = infra.DefaultLongRunningIdleTimeout + } + } if cfg.MaxLifetime != "" { d, err := time.ParseDuration(cfg.MaxLifetime) if err != nil { diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index d8412014..9e3a58c5 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -22,6 +22,7 @@ type Box struct { lastCall atomic.Int64 lastHeartbeat atomic.Int64 processCount atomic.Int32 + status atomic.Value // string: "running", "exited", "stopped", "created", "unknown" idleTimeoutD time.Duration maxLifetimeD time.Duration stopTimeoutD time.Duration @@ -210,14 +211,18 @@ func (b *Box) GetWorkDir() string { func (b *Box) WorkspaceID() string { return b.workspaceID } // Snapshot returns a local-only BoxInfo snapshot without any remote calls. -// Status is inferred from local state (not from the container runtime). +// Status is maintained by the sandbox watcher (see watcher.go). func (b *Box) Snapshot() BoxInfo { + s, _ := b.status.Load().(string) + if s == "" { + s = "unknown" + } return BoxInfo{ ID: b.id, ContainerID: b.containerID, NodeID: b.nodeID, Owner: b.owner, - Status: "running", + Status: s, Policy: b.policy, Labels: b.labels, Image: b.image, @@ -313,6 +318,12 @@ func (b *Box) lastActiveTime() time.Time { return time.UnixMilli(ts) } +// idleSince returns the timestamp of the last business call (Exec/Stream/VNC/etc). +// Unlike lastActiveTime, heartbeats do NOT reset this — only real user activity does. +func (b *Box) idleSince() time.Time { + return time.UnixMilli(b.lastCall.Load()) +} + func (b *Box) idleTimeout() time.Duration { return b.idleTimeoutD } @@ -327,3 +338,22 @@ func (b *Box) stopTimeout() time.Duration { } return DefaultStopTimeout } + +// IsStopped reports whether the box's last known status indicates a non-running container. +func (b *Box) IsStopped() bool { + s, _ := b.status.Load().(string) + return s == "exited" || s == "stopped" +} + +// inspectStatus queries the container runtime for the real container state. +func (b *Box) inspectStatus(ctx context.Context) string { + res, err := b.manager.getNode(b.nodeID) + if err != nil || res.Runtime == nil { + return "unknown" + } + info, err := res.Runtime.Inspect(ctx, b.containerID) + if err != nil { + return "unknown" + } + return info.Status +} diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go index ff26c2a6..b25f6976 100644 --- a/sandbox/v2/manager.go +++ b/sandbox/v2/manager.go @@ -19,20 +19,18 @@ import ( ) // Manager manages sandbox lifecycle. Node connections are delegated to tai/registry. +// Idle-timeout and lifecycle enforcement is handled by the sandbox watcher (watcher.go). type Manager struct { - boxes sync.Map - mu sync.Mutex - cancel context.CancelFunc + boxes sync.Map } func newManager() *Manager { return &Manager{} } -// Start discovers existing containers from all registered nodes, rebuilds -// the boxes map, and starts the cleanup loop. -// If no "local" node is registered yet, it probes the local Docker environment -// and auto-registers one when available. +// Start discovers existing containers from all registered nodes and rebuilds +// the boxes map. If no "local" node is registered yet, it probes the local +// Docker environment and auto-registers one when available. func (m *Manager) Start(ctx context.Context) error { reg := registry.Global() if reg == nil { @@ -49,9 +47,6 @@ func (m *Manager) Start(ctx context.Context) error { m.recoverBoxes(ctx, snap.TaiID, res) } - loopCtx, cancel := context.WithCancel(ctx) - m.cancel = cancel - go m.cleanupLoop(loopCtx) return nil } @@ -218,6 +213,7 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) displayName: opts.DisplayName, system: sys, } + box.status.Store("running") box.lastCall.Store(time.Now().UnixMilli()) m.boxes.Store(id, box) @@ -267,6 +263,31 @@ func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) { return result, nil } +// StartBox starts a stopped sandbox and updates its lastCall timestamp. +func (m *Manager) StartBox(ctx context.Context, id string) error { + v, ok := m.boxes.Load(id) + if !ok { + return ErrNotFound + } + b := v.(*Box) + + res, err := m.getNode(b.nodeID) + if err != nil { + return err + } + if res.Runtime == nil { + return fmt.Errorf("sandbox: node %q has no container runtime", b.nodeID) + } + + if err := res.Runtime.Start(ctx, b.containerID); err != nil { + return fmt.Errorf("sandbox: start container %s: %w", b.containerID, err) + } + + b.status.Store("running") + b.touch() + return nil +} + // Remove force-removes a sandbox (SIGKILL + delete). func (m *Manager) Remove(ctx context.Context, id string) error { v, ok := m.boxes.Load(id) @@ -284,58 +305,11 @@ func (m *Manager) Remove(ctx context.Context, id string) error { return nil } -// Cleanup removes idle/expired sandboxes. -func (m *Manager) Cleanup(ctx context.Context) error { - now := time.Now() - m.boxes.Range(func(key, value any) bool { - b := value.(*Box) - idle := now.Sub(b.lastActiveTime()) - - switch b.policy { - case OneShot: - // handled after Exec - case Session: - if timeout := b.idleTimeout(); timeout > 0 && idle > timeout { - m.Remove(ctx, b.id) - } - case LongRunning: - if timeout := b.idleTimeout(); timeout > 0 && idle > timeout { - if res, err := m.getNode(b.nodeID); err == nil && res.Runtime != nil { - res.Runtime.Stop(ctx, b.containerID, b.stopTimeout()) - } - } - if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime { - m.Remove(ctx, b.id) - } - case Persistent: - // never auto-cleaned - } - return true - }) - return nil -} - -// Close stops the cleanup loop. Node connections are managed by the registry. +// Close is a no-op; lifecycle management is handled by the sandbox watcher. func (m *Manager) Close() error { - if m.cancel != nil { - m.cancel() - } return nil } -func (m *Manager) cleanupLoop(ctx context.Context) { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - for { - select { - case <-ticker.C: - m.Cleanup(ctx) - case <-ctx.Done(): - return - } - } -} - func (m *Manager) getNode(name string) (*tai.ConnResources, error) { res, ok := tai.GetResources(name) if !ok { @@ -488,12 +462,13 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn if sys.OS == "" { sys = inferSystemInfo(ctx, res, c.Image) } + policy := LifecyclePolicy(c.Labels["sandbox-policy"]) box := &Box{ id: sandboxID, containerID: cid, nodeID: c.Labels["sandbox-node-id"], owner: c.Labels["sandbox-owner"], - policy: LifecyclePolicy(c.Labels["sandbox-policy"]), + policy: policy, labels: c.Labels, createdAt: time.Now(), image: c.Image, @@ -504,6 +479,12 @@ func (m *Manager) recoverBoxes(ctx context.Context, nodeID string, res *tai.Conn system: sys, manager: m, } + switch policy { + case Session: + box.idleTimeoutD = DefaultSessionIdleTimeout + case LongRunning: + box.idleTimeoutD = DefaultLongRunningIdleTimeout + } box.lastCall.Store(time.Now().UnixMilli()) m.boxes.Store(sandboxID, box) } diff --git a/sandbox/v2/manager_lifecycle_test.go b/sandbox/v2/manager_lifecycle_test.go index 67412792..f4afe12a 100644 --- a/sandbox/v2/manager_lifecycle_test.go +++ b/sandbox/v2/manager_lifecycle_test.go @@ -46,42 +46,6 @@ func TestHeartbeatUnknownBox(t *testing.T) { } } -func TestIdleCleanup(t *testing.T) { - skipIfNoDocker(t) - - for _, pc := range testNodes() { - pc := pc - t.Run(pc.Name, func(t *testing.T) { - m := setupManagerForNode(t, &pc) - ensureTestImage(t, m, pc.TaiID) - - ctx := context.Background() - box, err := m.Create(ctx, sandbox.CreateOptions{ - Image: testImage(), - Owner: "test-user", - NodeID: pc.TaiID, - Policy: sandbox.Session, - IdleTimeout: 1 * time.Second, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - boxID := box.ID() - - time.Sleep(2 * time.Second) - - if err := m.Cleanup(ctx); err != nil { - t.Fatalf("Cleanup: %v", err) - } - - _, err = m.Get(ctx, boxID) - if err != sandbox.ErrNotFound { - t.Errorf("after idle cleanup, Get err = %v, want ErrNotFound", err) - } - }) - } -} - func TestStartRecovery(t *testing.T) { skipIfNoDocker(t) @@ -114,27 +78,50 @@ func TestStartRecovery(t *testing.T) { } } -func TestPersistentNotCleaned(t *testing.T) { +func TestStartBox(t *testing.T) { skipIfNoDocker(t) for _, pc := range testNodes() { pc := pc t.Run(pc.Name, func(t *testing.T) { m := setupManagerForNode(t, &pc) - - box := createTestBox(t, m, pc, func(co *sandbox.CreateOptions) { - co.Policy = sandbox.Persistent - co.IdleTimeout = 1 * time.Second - }) - - time.Sleep(2 * time.Second) - + box := createTestBox(t, m, pc) + boxID := box.ID() ctx := context.Background() - m.Cleanup(ctx) - _, err := m.Get(ctx, box.ID()) + if err := box.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + if err := m.StartBox(ctx, boxID); err != nil { + t.Fatalf("StartBox: %v", err) + } + + info, err := box.Info(ctx) if err != nil { - t.Errorf("persistent box should not be cleaned: %v", err) + t.Fatalf("Info after StartBox: %v", err) + } + if info.Status != "running" { + t.Errorf("status = %q after StartBox, want running", info.Status) + } + }) + } +} + +func TestSnapshotReadsStatus(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testNodes() { + pc := pc + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForNode(t, &pc) + box := createTestBox(t, m, pc) + + snap := box.Snapshot() + if snap.Status != "running" { + t.Errorf("initial snapshot status = %q, want running", snap.Status) } }) } diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 828ef799..109c25b4 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -71,7 +71,11 @@ const ( Persistent LifecyclePolicy = "persistent" ) -const DefaultStopTimeout = 2 * time.Second +const ( + DefaultStopTimeout = 2 * time.Second + DefaultSessionIdleTimeout = 30 * time.Minute + DefaultLongRunningIdleTimeout = 2 * time.Hour +) // --------------------------------------------------------------------------- // Create / List options diff --git a/sandbox/v2/watcher.go b/sandbox/v2/watcher.go new file mode 100644 index 00000000..e0c8315f --- /dev/null +++ b/sandbox/v2/watcher.go @@ -0,0 +1,87 @@ +package sandbox + +import ( + "context" + "fmt" + "time" + + "github.com/yaoapp/yao/monitor" +) + +func init() { + monitor.Register(&sandboxWatcher{}) +} + +type sandboxWatcher struct{} + +func (w *sandboxWatcher) Name() string { return "sandbox" } +func (w *sandboxWatcher) Interval() time.Duration { return 30 * time.Second } + +func (w *sandboxWatcher) Check(ctx context.Context) []monitor.Alert { + if mgr == nil { + return nil + } + + var alerts []monitor.Alert + mgr.boxes.Range(func(_, v any) bool { + b := v.(*Box) + + status := b.inspectStatus(ctx) + old, _ := b.status.Swap(status).(string) + if old != "" && old != status { + alerts = append(alerts, monitor.Alert{ + Level: monitor.Info, + Target: "box:" + b.id, + Message: fmt.Sprintf("status %s → %s", old, status), + }) + } + + if status != "running" { + return true + } + + idle := time.Since(b.idleSince()) + timeout := b.idleTimeout() + if timeout <= 0 || idle <= timeout { + return true + } + + switch b.policy { + case Session: + alerts = append(alerts, monitor.Alert{ + Level: monitor.Warn, + Target: "box:" + b.id, + Message: fmt.Sprintf("session idle expired (idle=%s, timeout=%s), removing", idle.Round(time.Second), timeout), + Action: func(ctx context.Context) { + mgr.Remove(ctx, b.id) + }, + }) + + case LongRunning: + alerts = append(alerts, monitor.Alert{ + Level: monitor.Warn, + Target: "box:" + b.id, + Message: fmt.Sprintf("longrunning idle expired (idle=%s, timeout=%s), stopping", idle.Round(time.Second), timeout), + Action: func(ctx context.Context) { + b.Stop(ctx) + }, + }) + } + + 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 +} From a5c8109db86dea0cceab5963da9950aca2ddd528 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 13:17:58 +0800 Subject: [PATCH 5/9] feat(sandbox): enhance sandbox initialization and image management - Updated Docker run commands in CI workflows to include the `-direct` flag for improved server operation. - Implemented image existence checks and automatic pulling for sandbox environments, enhancing reliability during initialization. - Added loading status updates for sandbox operations, providing better feedback during the setup process. - Refactored lifecycle management to ensure accurate tracking of sandbox states and improved error handling. Made-with: Cursor --- .github/workflows/pr-test.yml | 2 +- .github/workflows/unit-test.yml | 2 +- agent/assistant/handlers/stream.go | 22 +++--- agent/assistant/sandbox_v2.go | 60 +++++++++++++--- agent/i18n/builtin.go | 39 +++++++---- agent/sandbox/v2/claude/parse.go | 109 +++++++++++++++++++++++++++-- agent/sandbox/v2/claude/runner.go | 12 ++++ agent/sandbox/v2/lifecycle.go | 55 +++++++++++++++ agent/sandbox/v2/lifecycle_test.go | 11 +-- agent/sandbox/v2/stream.go | 39 +++++++++-- tai/runtime/docker_core.go | 55 ++++++++++++++- 11 files changed, 355 insertions(+), 51 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index c9ee5d46..01e3f935 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1088,7 +1088,7 @@ jobs: docker run -d --name tai-docker \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \ - yaoapp/tai:latest server \ + yaoapp/tai:latest server -direct \ -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375 for i in $(seq 1 30); do diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c6765878..fa4a8061 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -799,7 +799,7 @@ jobs: docker run -d --name tai-docker \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 19100:19100 -p 8099:8099 -p 12375:12375 -p 16080:16080 \ - yaoapp/tai:latest server \ + yaoapp/tai:latest server -direct \ -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -docker 0.0.0.0:12375 for i in $(seq 1 30); do diff --git a/agent/assistant/handlers/stream.go b/agent/assistant/handlers/stream.go index 11407675..924c8125 100644 --- a/agent/assistant/handlers/stream.go +++ b/agent/assistant/handlers/stream.go @@ -224,35 +224,41 @@ func (s *streamState) handleToolCall(data []byte) int { var deltaPath string if len(toolCallArray) == 1 { - // Single tool call - flatten to props root level tc := toolCallArray[0] props = map[string]interface{}{} - // Static fields (only in first chunk): use merge + hasStaticFields := false if id, ok := tc["id"].(string); ok { props["id"] = id + hasStaticFields = true } if typ, ok := tc["type"].(string); ok { props["type"] = typ + hasStaticFields = true } if index, ok := tc["index"].(float64); ok { props["index"] = int(index) + hasStaticFields = true } if fn, ok := tc["function"].(map[string]interface{}); ok { if name, ok := fn["name"].(string); ok { props["name"] = name + hasStaticFields = true } - // Arguments field: use append if args, ok := fn["arguments"].(string); ok { props["arguments"] = args - // If this chunk has arguments, use append action for arguments field - deltaAction = "append" - deltaPath = "arguments" } } - // If no arguments in this chunk, use merge for other fields - if deltaAction == "" { + if hasStaticFields { + // First chunk with id/name/type: merge so all fields are applied. + // arguments="" is included but that's fine — subsequent appends build on it. + deltaAction = "merge" + } else if _, ok := props["arguments"]; ok { + // Subsequent chunk with only arguments fragment: append to arguments. + deltaAction = "append" + deltaPath = "arguments" + } else { deltaAction = "merge" } } else { diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index 5214ade3..772066d6 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -51,7 +51,34 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) // 2. Build human-readable DisplayName from real Agent name + Workspace name. cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name) + // 2.5. Image existence check + pull (for box mode). + if cfg.Computer.Image != "" && manager != nil { + nodeID, kind, _ := sandboxv2.ResolveNodeID(ctx, cfg, manager) + if kind == "box" && nodeID != "" { + updateLoadingV2(ctx, loadingMsgID, "sandbox.starting") + exists, existsErr := manager.ImageExists(stdCtx, nodeID, cfg.Computer.Image) + if existsErr != nil { + log.Printf("[sandbox/v2] image exists check failed on node %s: %v", nodeID, existsErr) + } + if existsErr == nil && !exists { + updateLoadingV2(ctx, loadingMsgID, "sandbox.pulling_image") + ch, pullErr := manager.PullImage(stdCtx, nodeID, cfg.Computer.Image, infraV2.ImagePullOptions{}) + if pullErr != nil { + log.Printf("[sandbox/v2] image pull failed on node %s: %v (will retry in Create)", nodeID, pullErr) + } else if ch != nil { + for p := range ch { + if p.Error != "" { + log.Printf("[sandbox/v2] image pull progress error: %s", p.Error) + break + } + } + } + } + } + } + // 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection). + updateLoadingV2(ctx, loadingMsgID, "sandbox.starting") computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn) if err != nil { closeLoadingV2(ctx, loadingMsgID, "sandbox.failed") @@ -89,6 +116,7 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) } // 7. Runner.Prepare (standard context). + updateLoadingV2(ctx, loadingMsgID, "sandbox.configuring") err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{ Computer: computer, Config: cfg, @@ -133,11 +161,6 @@ func (ast *Assistant) executeSandboxV2Stream( cfg := ast.SandboxV2 manager := infraV2.M() - // Close the "preparing" loading on first output. - if loadingMsgID != "" { - closeLoadingV2(ctx, loadingMsgID, "") - } - // Build system prompt. var systemPrompt string if len(ast.Prompts) > 0 { @@ -172,11 +195,12 @@ func (ast *Assistant) executeSandboxV2Stream( } execReq := &sandboxv2.ExecuteRequest{ - Computer: computer, - Runner: runner, - Config: cfg, - StreamReq: streamReq, - Manager: manager, + Computer: computer, + Runner: runner, + Config: cfg, + StreamReq: streamReq, + Manager: manager, + LoadingMsgID: loadingMsgID, } return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler) @@ -230,6 +254,22 @@ func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) stri return "" } +func updateLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) { + if loadingMsgID == "" || ctx == nil || msgKey == "" { + return + } + msg := &message.Message{ + MessageID: loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]any{ + "message": i18n.T(ctx.Locale, msgKey), + }, + } + ctx.Send(msg) +} + func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) { if loadingMsgID == "" || ctx == nil { return diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go index c73bac47..ad18ea1b 100644 --- a/agent/i18n/builtin.go +++ b/agent/i18n/builtin.go @@ -100,11 +100,14 @@ func init() { "kb.chat.description": "Auto-created knowledge base collection for chat sessions", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "Preparing sandbox environment", - "sandbox.ready": "Sandbox ready", - "sandbox.working": "Working on your request", - "sandbox.completed": "Completed", - "sandbox.failed": "Execution failed", + "sandbox.preparing": "Preparing sandbox environment", + "sandbox.ready": "Sandbox ready", + "sandbox.working": "Working on your request", + "sandbox.completed": "Completed", + "sandbox.failed": "Execution failed", + "sandbox.starting": "Starting sandbox environment", + "sandbox.configuring": "Configuring runtime environment", + "sandbox.pulling_image": "Pulling container image", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "Reading file", @@ -224,11 +227,14 @@ func init() { "kb.chat.description": "自动为聊天会话创建的知识库集合", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "正在准备沙箱环境", - "sandbox.ready": "沙箱环境就绪", - "sandbox.working": "正在处理您的请求", - "sandbox.completed": "处理完成", - "sandbox.failed": "执行失败", + "sandbox.preparing": "正在准备沙箱环境", + "sandbox.ready": "沙箱环境就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + "sandbox.starting": "正在启动沙箱环境", + "sandbox.configuring": "正在配置运行环境", + "sandbox.pulling_image": "正在拉取容器镜像", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "正在读取文件", @@ -376,11 +382,14 @@ func init() { "kb.chat.description": "自动为聊天会话创建的知识库集合", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "正在准备沙箱环境", - "sandbox.ready": "沙箱环境就绪", - "sandbox.working": "正在处理您的请求", - "sandbox.completed": "处理完成", - "sandbox.failed": "执行失败", + "sandbox.preparing": "正在准备沙箱环境", + "sandbox.ready": "沙箱环境就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + "sandbox.starting": "正在启动沙箱环境", + "sandbox.configuring": "正在配置运行环境", + "sandbox.pulling_image": "正在拉取容器镜像", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "正在读取文件", diff --git a/agent/sandbox/v2/claude/parse.go b/agent/sandbox/v2/claude/parse.go index a960a224..49f8e5c6 100644 --- a/agent/sandbox/v2/claude/parse.go +++ b/agent/sandbox/v2/claude/parse.go @@ -23,9 +23,13 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St scanner.Buffer(buf, 1024*1024) messageStarted := false + toolBlockActive := false + toolIndex := 0 type toolState struct { + id string name string + index int inputJSON strings.Builder } var currentTool *toolState @@ -66,10 +70,37 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St blockType, _ := cb["type"].(string) if blockType == "tool_use" { toolName, _ := cb["name"].(string) - currentTool = &toolState{name: toolName} + toolID, _ := cb["id"].(string) + if toolID == "" { + toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano()) + } + currentTool = &toolState{id: toolID, name: toolName, index: toolIndex} + toolIndex++ + if handler != nil { - data, _ := json.Marshal(map[string]any{"tool": toolName}) - if handler(message.ChunkToolCall, data) != 0 { + if !toolBlockActive { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()), + Type: "tool_call", + Timestamp: time.Now().UnixMilli(), + } + sd, _ := json.Marshal(startData) + if handler(message.ChunkMessageStart, sd) != 0 { + stopped = true + break + } + toolBlockActive = true + } + tcData, _ := json.Marshal([]map[string]any{{ + "index": currentTool.index, + "id": currentTool.id, + "type": "function", + "function": map[string]any{ + "name": toolName, + "arguments": "", + }, + }}) + if handler(message.ChunkToolCall, tcData) != 0 { stopped = true } } @@ -82,7 +113,13 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St switch deltaType { case "text_delta": if text, ok := delta["text"].(string); ok && text != "" { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") if handler != nil { + if toolBlockActive { + handler(message.ChunkMessageEnd, nil) + toolBlockActive = false + } if !messageStarted { startData := message.EventMessageStartData{ MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), @@ -105,6 +142,17 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St if currentTool != nil { if partial, ok := delta["partial_json"].(string); ok { currentTool.inputJSON.WriteString(partial) + if handler != nil { + tcData, _ := json.Marshal([]map[string]any{{ + "index": currentTool.index, + "function": map[string]any{ + "arguments": partial, + }, + }}) + if handler(message.ChunkToolCall, tcData) != 0 { + stopped = true + } + } } } } @@ -125,8 +173,53 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St continue } itemType, _ := ci["type"].(string) + + if itemType == "tool_use" && handler != nil { + toolName, _ := ci["name"].(string) + toolID, _ := ci["id"].(string) + if toolID == "" { + toolID = fmt.Sprintf("tool_%d_%d", toolIndex, time.Now().UnixNano()) + } + inputRaw, _ := json.Marshal(ci["input"]) + idx := toolIndex + toolIndex++ + + if !toolBlockActive { + startData := message.EventMessageStartData{ + MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()), + Type: "tool_call", + Timestamp: time.Now().UnixMilli(), + } + sd, _ := json.Marshal(startData) + if handler(message.ChunkMessageStart, sd) != 0 { + stopped = true + break + } + toolBlockActive = true + } + tcData, _ := json.Marshal([]map[string]any{{ + "index": idx, + "id": toolID, + "type": "function", + "function": map[string]any{ + "name": toolName, + "arguments": string(inputRaw), + }, + }}) + if handler(message.ChunkToolCall, tcData) != 0 { + stopped = true + break + } + } + if itemType == "text" { if text, ok := ci["text"].(string); ok && text != "" && handler != nil && !messageStarted { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + if toolBlockActive { + handler(message.ChunkMessageEnd, nil) + toolBlockActive = false + } startData := message.EventMessageStartData{ MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Type: "text", @@ -159,8 +252,14 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St return fmt.Errorf("Claude CLI error: %s", result) } } - if handler != nil && messageStarted { - handler(message.ChunkMessageEnd, nil) + if handler != nil { + if toolBlockActive { + handler(message.ChunkMessageEnd, nil) + toolBlockActive = false + } + if messageStarted { + handler(message.ChunkMessageEnd, nil) + } } case "error": diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index 576933a7..465414e2 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -227,6 +227,18 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isCo env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = model env["CLAUDE_CODE_SUBAGENT_MODEL"] = model } + + if thinking, ok := setting["thinking"].(map[string]interface{}); ok { + thinkType, _ := thinking["type"].(string) + switch thinkType { + case "disabled": + env["MAX_THINKING_TOKENS"] = "0" + case "enabled": + if budget, ok := thinking["budget_tokens"].(float64); ok && budget > 0 { + env["MAX_THINKING_TOKENS"] = fmt.Sprintf("%d", int(budget)) + } + } + } } if req.Config != nil && len(req.Config.Secrets) > 0 { diff --git a/agent/sandbox/v2/lifecycle.go b/agent/sandbox/v2/lifecycle.go index 3bbf0c8d..4419bb7b 100644 --- a/agent/sandbox/v2/lifecycle.go +++ b/agent/sandbox/v2/lifecycle.go @@ -32,6 +32,61 @@ func BuildIdentifier(cfg *types.SandboxConfig, ownerID, chatID, assistantID, wor } } +// ResolveNodeID determines the target nodeID and computer kind based on +// metadata and DSL configuration, without creating or acquiring a container. +// Returns (nodeID, kind, error). kind is "box" or "host". +func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *infra.Manager) (string, string, error) { + computerID := "" + if ctx.Metadata != nil { + if cid, ok := ctx.Metadata["computer_id"].(string); ok && cid != "" { + computerID = cid + } + } + + workspaceID := "" + if ctx.Metadata != nil { + if ws, ok := ctx.Metadata["workspace_id"].(string); ok && ws != "" { + workspaceID = ws + } + } + ownerID := resolveOwnerID(ctx) + if workspaceID == "" { + workspaceID = ownerID + } + + if workspaceID != "" && workspaceID != ownerID { + wsNode, err := workspace.M().NodeForWorkspace(context.Background(), workspaceID) + if err == nil && wsNode != "" { + computerID = wsNode + } + } + + if computerID != "" { + if node, ok := tai.GetNodeMeta(computerID); ok { + hasContainerRuntime := node.Capabilities.Docker || node.Capabilities.K8s + if node.Capabilities.HostExec && !hasContainerRuntime { + return computerID, "host", nil + } + if node.Capabilities.HostExec && hasContainerRuntime && cfg.Computer.Image == "" { + return computerID, "host", nil + } + if !hasContainerRuntime { + return "", "", fmt.Errorf("node %q has no container runtime and no host_exec capability", computerID) + } + return computerID, "box", nil + } + return computerID, "box", nil + } + + if cfg.Computer.Image == "" { + nodeID := cfg.NodeID + return nodeID, "host", nil + } + + nodeID := cfg.NodeID + return nodeID, "box", nil +} + // GetComputer obtains or creates a Computer for the current request. // An optional connector may be passed to inject OPENAI_PROXY_* env vars. // Returns the Computer, the resolved identifier, and any error. diff --git a/agent/sandbox/v2/lifecycle_test.go b/agent/sandbox/v2/lifecycle_test.go index 4d1afe4d..f9e808eb 100644 --- a/agent/sandbox/v2/lifecycle_test.go +++ b/agent/sandbox/v2/lifecycle_test.go @@ -29,8 +29,8 @@ func TestBuildIdentifier_Oneshot(t *testing.T) { func TestBuildIdentifier_Session(t *testing.T) { cfg := &types.SandboxConfig{Lifecycle: "session"} id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", nil) - if id != "owner1-chat42" { - t.Errorf("session: got %q, want %q", id, "owner1-chat42") + if id != "owner1-ast1-chat42" { + t.Errorf("session: got %q, want %q", id, "owner1-ast1-chat42") } } @@ -54,8 +54,9 @@ func TestBuildIdentifier_MetadataOverride(t *testing.T) { cfg := &types.SandboxConfig{Lifecycle: "session"} meta := map[string]any{"computer_id": "custom-box"} id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat1", "ast1", "ws1", meta) - if id != "owner1-custom-box.ws1" { - t.Errorf("metadata override: got %q, want %q", id, "owner1-custom-box.ws1") + // computer_id is used for routing only, not for identifier generation. + if id != "owner1-ast1-chat1" { + t.Errorf("metadata override: got %q, want %q", id, "owner1-ast1-chat1") } } @@ -63,7 +64,7 @@ func TestBuildIdentifier_MetadataEmptyIgnored(t *testing.T) { cfg := &types.SandboxConfig{Lifecycle: "session"} meta := map[string]any{"computer_id": ""} id := sandboxv2.BuildIdentifier(cfg, "owner1", "chat42", "ast1", "ws1", meta) - if id != "owner1-chat42" { + if id != "owner1-ast1-chat42" { t.Errorf("empty metadata should fall through to session, got %q", id) } } diff --git a/agent/sandbox/v2/stream.go b/agent/sandbox/v2/stream.go index 2066cac5..50a5d94e 100644 --- a/agent/sandbox/v2/stream.go +++ b/agent/sandbox/v2/stream.go @@ -15,11 +15,12 @@ import ( // ExecuteRequest consolidates all parameters for ExecuteSandboxStream. type ExecuteRequest struct { - Computer infra.Computer - Runner types.Runner - Config *types.SandboxConfig - StreamReq *types.StreamRequest - Manager *infra.Manager + Computer infra.Computer + Runner types.Runner + Config *types.SandboxConfig + StreamReq *types.StreamRequest + Manager *infra.Manager + LoadingMsgID string } // ExecuteSandboxStream is the V2 replacement for executeSandboxStream. @@ -106,7 +107,14 @@ func ExecuteSandboxStream( }() var textContent []byte + loadingClosed := false wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int { + if !loadingClosed && req.LoadingMsgID != "" { + if chunkType == message.ChunkText || chunkType == message.ChunkToolCall || chunkType == message.ChunkMessageStart { + closeLoading(ctx, req.LoadingMsgID) + loadingClosed = true + } + } if chunkType == message.ChunkText { textContent = append(textContent, data...) } @@ -118,6 +126,10 @@ func ExecuteSandboxStream( err := req.Runner.Stream(runnerCtx, req.StreamReq, wrappedHandler) + if !loadingClosed && req.LoadingMsgID != "" { + closeLoading(ctx, req.LoadingMsgID) + } + panicked = false // Normal exit reached. if err != nil { @@ -136,3 +148,20 @@ func ExecuteSandboxStream( } return resp, nil } + +func closeLoading(ctx *agentContext.Context, loadingMsgID string) { + if loadingMsgID == "" || ctx == nil { + return + } + msg := &message.Message{ + MessageID: loadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]any{ + "done": true, + "message": "", + }, + } + ctx.Send(msg) +} diff --git a/tai/runtime/docker_core.go b/tai/runtime/docker_core.go index 4394f7c7..27c99363 100644 --- a/tai/runtime/docker_core.go +++ b/tai/runtime/docker_core.go @@ -31,7 +31,7 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts } hostCfg := &container.HostConfig{ - Binds: opts.Binds, + Binds: normalizeBinds(opts.Binds), ExtraHosts: []string{"host.tai.internal:host-gateway"}, } @@ -282,3 +282,56 @@ func (d *dockerCore) list(ctx context.Context, opts ListOptions) ([]ContainerInf } return result, nil } + +// normalizeBinds converts Windows-style host paths in Docker bind-mount +// specifications to WSL2 mount paths that Docker (running in WSL2) accepts. +// e.g. "D:\volumes\ws-abc:/workspace:rw" -> "/mnt/d/volumes/ws-abc:/workspace:rw" +// +// Detection is based on the path content (drive-letter prefix), not runtime.GOOS, +// because the path may originate from a remote Tai node (Windows) while Yao +// runs on macOS/Linux. +func normalizeBinds(binds []string) []string { + if len(binds) == 0 { + return binds + } + out := make([]string, len(binds)) + changed := false + for i, b := range binds { + out[i] = normalizeWindowsBind(b) + if out[i] != b { + changed = true + } + } + if !changed { + return binds + } + return out +} + +// normalizeWindowsBind handles a single bind spec "hostPath:containerPath[:mode]". +// When Yao runs on Windows and Docker runs in WSL2, Windows paths like +// "D:\volumes\ws-abc" must be converted to "/mnt/d/volumes/ws-abc" because +// WSL2 mounts Windows drives under /mnt//. +func normalizeWindowsBind(bind string) string { + if len(bind) < 3 { + return bind + } + + // Detect drive-letter prefix: "X:\" or "X:/" + if bind[1] != ':' || (bind[2] != '\\' && bind[2] != '/') { + return bind + } + + // Find the next colon after the drive letter colon (the bind separator) + idx := strings.Index(bind[2:], ":") + if idx < 0 { + return bind + } + hostPath := bind[:2+idx] + rest := bind[2+idx:] // starts with ":" + + // Convert "D:\foo\bar" -> "/mnt/d/foo/bar" + drive := strings.ToLower(string(hostPath[0])) + tail := strings.ReplaceAll(hostPath[2:], `\`, `/`) + return "/mnt/" + drive + tail + rest +} From 89e5840490c10e1d4179849f35657f76f0eae9c6 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 13:52:38 +0800 Subject: [PATCH 6/9] feat(sandbox): update sandbox status messages and improve loading feedback - Modified Docker run commands in CI workflows to include the `-direct` flag for enhanced server operation. - Removed outdated loading status message during sandbox preparation and added new messages for improved user feedback. - Introduced a loading message for waiting on AI responses, enhancing the user experience during sandbox execution. - Refactored identity handling in stream processing to streamline message management. Made-with: Cursor --- .github/workflows/pr-test.yml | 2 +- .github/workflows/unit-test.yml | 2 +- agent/assistant/handlers/stream.go | 12 ++++---- agent/assistant/sandbox_v2.go | 1 - agent/i18n/builtin.go | 48 +++++++++++++++--------------- agent/sandbox/v2/claude/parse.go | 5 ++++ agent/sandbox/v2/stream.go | 14 +++++++++ 7 files changed, 50 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 01e3f935..92f975da 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1142,7 +1142,7 @@ jobs: -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ - yaoapp/tai:latest server \ + yaoapp/tai:latest server -direct \ -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443 for i in $(seq 1 30); do diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index fa4a8061..2de1fb81 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -853,7 +853,7 @@ jobs: -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ - yaoapp/tai:latest server \ + yaoapp/tai:latest server -direct \ -grpc 0.0.0.0:19100 -http 0.0.0.0:8099 -vnc 0.0.0.0:16080 -k8s 0.0.0.0:16443 for i in $(seq 1 30); do diff --git a/agent/assistant/handlers/stream.go b/agent/assistant/handlers/stream.go index 924c8125..1d0d1b6b 100644 --- a/agent/assistant/handlers/stream.go +++ b/agent/assistant/handlers/stream.go @@ -227,32 +227,30 @@ func (s *streamState) handleToolCall(data []byte) int { tc := toolCallArray[0] props = map[string]interface{}{} - hasStaticFields := false + hasIdentity := false if id, ok := tc["id"].(string); ok { props["id"] = id - hasStaticFields = true + hasIdentity = true } if typ, ok := tc["type"].(string); ok { props["type"] = typ - hasStaticFields = true + hasIdentity = true } if index, ok := tc["index"].(float64); ok { props["index"] = int(index) - hasStaticFields = true } if fn, ok := tc["function"].(map[string]interface{}); ok { if name, ok := fn["name"].(string); ok { props["name"] = name - hasStaticFields = true + hasIdentity = true } if args, ok := fn["arguments"].(string); ok { props["arguments"] = args } } - if hasStaticFields { + if hasIdentity { // First chunk with id/name/type: merge so all fields are applied. - // arguments="" is included but that's fine — subsequent appends build on it. deltaAction = "merge" } else if _, ok := props["arguments"]; ok { // Subsequent chunk with only arguments fragment: append to arguments. diff --git a/agent/assistant/sandbox_v2.go b/agent/assistant/sandbox_v2.go index 772066d6..027e07b4 100644 --- a/agent/assistant/sandbox_v2.go +++ b/agent/assistant/sandbox_v2.go @@ -116,7 +116,6 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) } // 7. Runner.Prepare (standard context). - updateLoadingV2(ctx, loadingMsgID, "sandbox.configuring") err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{ Computer: computer, Config: cfg, diff --git a/agent/i18n/builtin.go b/agent/i18n/builtin.go index ad18ea1b..c6bdb1e7 100644 --- a/agent/i18n/builtin.go +++ b/agent/i18n/builtin.go @@ -100,14 +100,14 @@ func init() { "kb.chat.description": "Auto-created knowledge base collection for chat sessions", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "Preparing sandbox environment", - "sandbox.ready": "Sandbox ready", - "sandbox.working": "Working on your request", - "sandbox.completed": "Completed", - "sandbox.failed": "Execution failed", - "sandbox.starting": "Starting sandbox environment", - "sandbox.configuring": "Configuring runtime environment", - "sandbox.pulling_image": "Pulling container image", + "sandbox.preparing": "Getting things ready...", + "sandbox.ready": "Sandbox ready", + "sandbox.working": "Working on your request", + "sandbox.completed": "Completed", + "sandbox.failed": "Execution failed", + "sandbox.starting": "Setting up workspace...", + "sandbox.pulling_image": "Preparing environment (first time may take a moment)", + "sandbox.waiting_response": "Waiting for AI response...", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "Reading file", @@ -227,14 +227,14 @@ func init() { "kb.chat.description": "自动为聊天会话创建的知识库集合", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "正在准备沙箱环境", - "sandbox.ready": "沙箱环境就绪", - "sandbox.working": "正在处理您的请求", - "sandbox.completed": "处理完成", - "sandbox.failed": "执行失败", - "sandbox.starting": "正在启动沙箱环境", - "sandbox.configuring": "正在配置运行环境", - "sandbox.pulling_image": "正在拉取容器镜像", + "sandbox.preparing": "正在准备...", + "sandbox.ready": "就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + "sandbox.starting": "正在启动工作区...", + "sandbox.pulling_image": "正在准备运行环境(首次可能需要一点时间)", + "sandbox.waiting_response": "等待 AI 响应...", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "正在读取文件", @@ -382,14 +382,14 @@ func init() { "kb.chat.description": "自动为聊天会话创建的知识库集合", // Sandbox: assistant/sandbox.go - Sandbox status messages - "sandbox.preparing": "正在准备沙箱环境", - "sandbox.ready": "沙箱环境就绪", - "sandbox.working": "正在处理您的请求", - "sandbox.completed": "处理完成", - "sandbox.failed": "执行失败", - "sandbox.starting": "正在启动沙箱环境", - "sandbox.configuring": "正在配置运行环境", - "sandbox.pulling_image": "正在拉取容器镜像", + "sandbox.preparing": "正在准备...", + "sandbox.ready": "就绪", + "sandbox.working": "正在处理您的请求", + "sandbox.completed": "处理完成", + "sandbox.failed": "执行失败", + "sandbox.starting": "正在启动工作区...", + "sandbox.pulling_image": "正在准备运行环境(首次可能需要一点时间)", + "sandbox.waiting_response": "等待 AI 响应...", // Sandbox: claude/executor.go - Tool execution messages "sandbox.tool.read": "正在读取文件", diff --git a/agent/sandbox/v2/claude/parse.go b/agent/sandbox/v2/claude/parse.go index 49f8e5c6..af8c2a6c 100644 --- a/agent/sandbox/v2/claude/parse.go +++ b/agent/sandbox/v2/claude/parse.go @@ -78,6 +78,10 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St toolIndex++ if handler != nil { + if messageStarted { + handler(message.ChunkMessageEnd, nil) + messageStarted = false + } if !toolBlockActive { startData := message.EventMessageStartData{ MessageID: fmt.Sprintf("sandbox-tool-%d", time.Now().UnixNano()), @@ -119,6 +123,7 @@ func parseStreamJSON(_ context.Context, stdout io.ReadCloser, handler message.St if toolBlockActive { handler(message.ChunkMessageEnd, nil) toolBlockActive = false + messageStarted = false } if !messageStarted { startData := message.EventMessageStartData{ diff --git a/agent/sandbox/v2/stream.go b/agent/sandbox/v2/stream.go index 50a5d94e..4816af04 100644 --- a/agent/sandbox/v2/stream.go +++ b/agent/sandbox/v2/stream.go @@ -8,6 +8,7 @@ import ( "time" agentContext "github.com/yaoapp/yao/agent/context" + "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" "github.com/yaoapp/yao/agent/sandbox/v2/types" infra "github.com/yaoapp/yao/sandbox/v2" @@ -106,6 +107,19 @@ func ExecuteSandboxStream( } }() + if req.LoadingMsgID != "" { + waitMsg := &message.Message{ + MessageID: req.LoadingMsgID, + Delta: true, + DeltaAction: message.DeltaReplace, + Type: message.TypeLoading, + Props: map[string]any{ + "message": i18n.T(ctx.Locale, "sandbox.waiting_response"), + }, + } + ctx.Send(waitMsg) + } + var textContent []byte loadingClosed := false wrappedHandler := func(chunkType message.StreamChunkType, data []byte) int { From fddd137806d3522a2f3fe3521586f472fcf794ce Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 17:22:44 +0800 Subject: [PATCH 7/9] fix(sandbox): update token generation and improve ClaudeRunner file handling - Changed token generation from "sandbox:mcp" to "grpc:mcp" for both access and refresh tokens, aligning with updated service requirements. - Enhanced ClaudeRunner to copy skills from the specified directory to the ".claude/skills" path, improving skill management. - Updated MCP configuration file path to ".claude/mcp.json" for better organization and consistency in file handling. - Added error logging for skill copying and exit code handling in stream execution, enhancing debugging capabilities. Made-with: Cursor --- agent/sandbox/v2/claude/runner.go | 26 +++++++++++++++----------- agent/sandbox/v2/token.go | 4 ++-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/agent/sandbox/v2/claude/runner.go b/agent/sandbox/v2/claude/runner.go index 465414e2..51a38c24 100644 --- a/agent/sandbox/v2/claude/runner.go +++ b/agent/sandbox/v2/claude/runner.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "path" "strings" "time" @@ -41,17 +42,17 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e r.mode = "cli" } - env := resolveOSEnv(req.Computer, req.Config) - steps := append([]types.PrepareStep{}, req.Config.Prepare...) if req.SkillsDir != "" { - claudeDir := env.pathJoin(env.WorkDir, ".claude") - steps = append(steps, types.PrepareStep{ - Action: "exec", - Cmd: env.mkdirCmd(claudeDir), - Once: true, - }) + ws := req.Computer.Workplace() + if ws != nil { + src := "local:///" + req.SkillsDir + dst := ".claude/skills" + if _, err := ws.Copy(src, dst); err != nil { + fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err) + } + } } if len(req.MCPServers) > 0 { @@ -60,7 +61,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e mcpJSON := buildMCPConfig(req.MCPServers) steps = append(steps, types.PrepareStep{ Action: "file", - Path: env.pathJoin(env.WorkDir, ".mcp.json"), + Path: ".claude/mcp.json", Content: mcpJSON, }) } @@ -103,6 +104,8 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han streamOpts = append(streamOpts, infra.WithStdin(stdin)) } + fmt.Fprintf(os.Stderr, "[claude] Stream cmd=%v hasMCP=%v isContinuation=%v stdinLen=%d\n", cmd, r.hasMCP, isContinuation, len(stdin)) + execStream, err := computer.Stream(ctx, cmd, streamOpts...) if err != nil { return fmt.Errorf("computer.Stream: %w", err) @@ -157,6 +160,7 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han return waitErr } if exitCode != 0 { + fmt.Fprintf(os.Stderr, "[claude] exit code=%d parseErr=%v waitErr=%v stderr=%q\n", exitCode, parseErr, waitErr, stderrStr) if stderrStr != "" { return fmt.Errorf("claude CLI exited with code %d: %s", exitCode, stderrStr) } @@ -302,7 +306,7 @@ func (r *ClaudeRunner) buildCLICommand(req *types.StreamRequest, oe *osEnv, isCo } if r.hasMCP { - mcpPath := oe.pathJoin(oe.WorkDir, ".mcp.json") + mcpPath := oe.pathJoin(oe.WorkDir, ".claude", "mcp.json") args = append(args, "--mcp-config", mcpPath) if r.mcpToolPattern != "" { args = append(args, "--allowedTools", r.mcpToolPattern) @@ -327,7 +331,7 @@ func buildMCPConfig(servers []types.MCPServer) []byte { } mcpServers[name] = map[string]any{ "command": "tai", - "args": []string{"mcp"}, + "args": []string{"mcp", name}, } } if len(mcpServers) == 0 { diff --git a/agent/sandbox/v2/token.go b/agent/sandbox/v2/token.go index dc47f9d0..956c39cd 100644 --- a/agent/sandbox/v2/token.go +++ b/agent/sandbox/v2/token.go @@ -70,7 +70,7 @@ func IssueSandboxToken(teamID, userID string) (*types.SandboxToken, error) { extraClaims["team_id"] = teamID } - tokenStr, err := svc.MakeAccessToken("__yao.sandbox", "sandbox:mcp", subject, + tokenStr, err := svc.MakeAccessToken("__yao.sandbox", "grpc:mcp", subject, int(accessTokenTTL.Seconds()), extraClaims) if err != nil { return nil, fmt.Errorf("sandbox token: issue access token: %w", err) @@ -78,7 +78,7 @@ func IssueSandboxToken(teamID, userID string) (*types.SandboxToken, error) { tok := &types.SandboxToken{Token: tokenStr} - refreshStr, err := svc.MakeRefreshToken("__yao.sandbox", "sandbox:mcp", subject, + refreshStr, err := svc.MakeRefreshToken("__yao.sandbox", "grpc:mcp", subject, int(refreshTokenTTL.Seconds()), extraClaims) if err != nil { return nil, fmt.Errorf("sandbox token: issue refresh token: %w", err) From 128d9b174f3e375d3997c82ed0c681c597b484eb Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 17:39:35 +0800 Subject: [PATCH 8/9] feat(workflow): enhance macOS release process with improved build steps and version handling - Updated the macOS release workflow to include a version input, allowing for dynamic version specification. - Refactored build steps to streamline the setup of Node.js, pnpm, and Go tools, improving build efficiency. - Added multiple repository checkouts for dependencies, ensuring all necessary components are available for the build. - Implemented certificate management for code signing, enhancing the security of the release process. - Improved artifact creation and signing steps, ensuring a more robust and reliable release pipeline. Made-with: Cursor --- .github/workflows/notarize-macos.yml | 88 ++++++++ .github/workflows/release-linux.yml | 219 ++++++++++++++++++++ .github/workflows/release-macos.yml | 291 ++++++++++++++++++++++----- 3 files changed, 546 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/notarize-macos.yml create mode 100644 .github/workflows/release-linux.yml diff --git a/.github/workflows/notarize-macos.yml b/.github/workflows/notarize-macos.yml new file mode 100644 index 00000000..20b9dd13 --- /dev/null +++ b/.github/workflows/notarize-macos.yml @@ -0,0 +1,88 @@ +name: Notarize macOS + +on: + workflow_dispatch: + inputs: + run_id: + description: "Release macOS workflow run ID (to download artifacts from)" + required: true + version: + description: "Version used in the release build (e.g. 1.0.0 or 1.0.0-alpha)" + required: true + +permissions: + contents: write + +jobs: + # =================================================================== + # Notarize Yao binaries (arm64 + amd64) + # =================================================================== + notarize: + runs-on: macos-latest + strategy: + matrix: + arch: [arm64, amd64] + steps: + - name: Download Yao Binary + uses: actions/download-artifact@v4 + with: + name: yao-darwin-${{ matrix.arch }} + path: bin + run-id: ${{ github.event.inputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Certificates + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + mkdir -p certs + echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer + echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer + echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12 + security verify-cert -c certs/DeveloperIDG2CA.cer + security verify-cert -c certs/distribution.cer + + - name: Import Certificates + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign + security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign + security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign + security list-keychain -d user -s $KEYCHAIN_PATH + + - name: Verify Signature + run: codesign --verify --deep --strict --verbose=2 bin/yao + + - name: Notarize Yao ${{ matrix.arch }} + timeout-minutes: 15 + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAME_ID: ${{ secrets.APPLE_TEAME_ID }} + APPLE_APP_SPEC_PASS: ${{ secrets.APPLE_APP_SPEC_PASS }} + run: | + zip -j bin/yao.zip bin/yao + + SUBMIT_OUT=$(xcrun notarytool submit bin/yao.zip \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAME_ID" \ + --password "$APPLE_APP_SPEC_PASS" \ + --wait --timeout 10m --output-format json 2>&1) || true + echo "$SUBMIT_OUT" + + STATUS=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true) + SUB_ID=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ "$STATUS" != "Accepted" ]; then + echo "::error::Yao ${{ matrix.arch }} notarization failed (status: $STATUS)" + [ -n "$SUB_ID" ] && xcrun notarytool log "$SUB_ID" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAME_ID" \ + --password "$APPLE_APP_SPEC_PASS" || true + exit 1 + fi + echo "Yao ${{ matrix.arch }} notarization accepted." diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 00000000..aa6f572a --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,219 @@ +name: Release Linux + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 1.0.0 or 1.0.0-alpha). Leave empty to read from share/const.go." + required: false + push: + tags: + - "v*" + +permissions: + contents: write + +env: + IMAGE_NAME: yaoapp/yao + +jobs: + # =================================================================== + # Build Linux Binaries (amd64 + arm64) + # Uses the yaoapp/yao-build container which has all cross-compile deps. + # =================================================================== + build-linux: + runs-on: ubuntu-latest + container: + image: yaoapp/yao-build:1.0.0 + env: + CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} + CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + steps: + - name: Configure R2 For Cloudflare + run: | + aws configure set aws_access_key_id $CF_ACCESS_KEY_ID + aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY + aws configure set default.region us-east-1 + aws configure set default.s3.signature_version s3v4 + aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com + + - name: Build + run: | + export PATH=$PATH:/github/home/go/bin + /app/build.sh + ls -l /data + + - name: Get Version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + else + VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + fi + + - name: Push To R2 + run: | + for file in /data/*; do + aws s3 cp "$file" s3://$R2_BUCKET/archives/ \ + --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com + done + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: yao-linux + path: /data/* + + # =================================================================== + # GitHub Release + Tag + # =================================================================== + release: + needs: build-linux + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Get Version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + else + VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + fi + + - name: Download Linux Artifacts + uses: actions/download-artifact@v4 + with: + name: yao-linux + path: dist + + - name: List Artifacts + run: ls -lh dist/ + + - name: Create Tag (if manual dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}" || true + git push origin "v${{ steps.version.outputs.version }}" || true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: Yao v${{ steps.version.outputs.version }} + files: dist/* + generate_release_notes: true + + # =================================================================== + # Docker Images (multi-platform linux/amd64 + linux/arm64) + # Builds after R2 upload so Dockerfiles can curl binaries from R2. + # =================================================================== + docker: + needs: build-linux + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Get Version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + else + VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build & Push Development (amd64) + uses: docker/build-push-action@v6 + with: + context: ./docker/development + platforms: linux/amd64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=amd64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64-dev + + - name: Build & Push Development (arm64) + uses: docker/build-push-action@v6 + with: + context: ./docker/development + platforms: linux/arm64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=arm64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64-dev + + - name: Build & Push Production (amd64) + uses: docker/build-push-action@v6 + with: + context: ./docker/production + platforms: linux/amd64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=amd64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64 + + - name: Build & Push Production (arm64) + uses: docker/build-push-action@v6 + with: + context: ./docker/production + platforms: linux/arm64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=arm64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64 + + - name: Build & Push Slim (amd64) + uses: docker/build-push-action@v6 + with: + context: ./docker/production-slim + platforms: linux/amd64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=amd64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64-slim + + - name: Build & Push Slim (arm64) + uses: docker/build-push-action@v6 + with: + context: ./docker/production-slim + platforms: linux/arm64 + build-args: | + VERSION=${{ steps.version.outputs.version }}-unstable + ARCH=arm64 + push: true + tags: ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64-slim diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 43d2353a..2229bc62 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -1,64 +1,251 @@ -name: Release MacOS Artifacts +name: Release macOS on: workflow_dispatch: + inputs: + version: + description: "Release version (e.g. 1.0.0 or 1.0.0-alpha). Leave empty to read from share/const.go." + required: false + +permissions: + contents: write jobs: - release: - runs-on: "macos-12" - timeout-minutes: 120 - + # =================================================================== + # Build Yao macOS binaries (arm64 + amd64) — one job, both arches + # =================================================================== + build: + runs-on: macos-latest steps: - - name: Download latest artifacts + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install pnpm + run: npm install -g pnpm + + - name: Setup Cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Checkout Kun + uses: actions/checkout@v4 + with: + repository: yaoapp/kun + path: kun + + - name: Checkout Xun + uses: actions/checkout@v4 + with: + repository: yaoapp/xun + path: xun + + - name: Checkout Gou + uses: actions/checkout@v4 + with: + repository: yaoapp/gou + path: gou + + - name: Checkout V8Go + uses: actions/checkout@v4 + with: + repository: yaoapp/v8go + path: v8go + + - name: Unzip libv8 run: | - echo "Downloading latest artifacts..." - ARTIFACT_URL="https://api.github.com/repos/YaoApp/yao/actions/artifacts" - ARTIFACT_CONTENT=$(curl -s -H "Accept: application/vnd.github.v3+json" $ARTIFACT_URL) - echo $ARTIFACT_CONTENT - ARTIFACTS=$(echo $ARTIFACT_CONTENT | jq -r '.artifacts[] | select(.name | contains("yao-macos")) | .id') - for id in $ARTIFACTS; do - echo "https://api.github.com/repos/YaoApp/yao/actions/artifacts/$id/zip" - curl -L -H "Accept: application/vnd.github.v3+json" \ - "https://api.github.com/repos/YaoApp/yao/actions/artifacts/$id/zip" \ - -o artifact.zip - unzip artifact.zip -d ./artifacts - rm artifact.zip - break + files=$(find ./v8go -name "libv8*.zip") + for file in $files; do + dir=$(dirname "$file") + echo "Extracting $file to directory $dir" + unzip -o -d $dir $file + rm -rf $dir/__MACOSX done - ls -l ./artifacts - # - name: Submit notarization request - # run: | - # echo "Submitting notarization request..." - # UUID=$(xcrun altool --notarize-app --primary-bundle-id "com.example.yourapp" \ - # --username "your-apple-id" --password "app-specific-password" \ - # --file ./artifacts/your-binary-file) + - name: Checkout CUI v1.0 + uses: actions/checkout@v4 + with: + repository: yaoapp/cui + path: cui-v1.0 - # echo "Notarization UUID: $UUID" - # echo "$UUID" > notarization_uuid.txt + - name: Checkout Yao-Init + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-init + path: yao-init - # - name: Check notarization status - # id: check_notarization - # timeout-minutes: 120 - # run: | - # UUID=$(cat notarization_uuid.txt) - # STATUS="in progress" - # while [[ "$STATUS" == "in progress" ]]; do - # STATUS=$(xcrun altool --notarization-info "$UUID" \ - # --username "your-apple-id" --password "app-specific-password") - # echo "Notarization status: $STATUS" - # if [[ "$STATUS" == *"success"* ]]; then - # echo "::set-output name=status::success" - # break - # elif [[ "$STATUS" == *"invalid"* ]]; then - # echo "::set-output name=status::failed" - # break - # fi - # done + - name: Move Dependencies + run: | + mv kun ../ + mv xun ../ + mv gou ../ + mv v8go ../ + mv cui-v1.0 ../ + mv yao-init ../ + rm -f ../cui-v1.0/packages/setup/vite.config.ts.* - # - name: Create Release - # if: steps.check_notarization.outputs.status == 'success' - # run: | - # echo "Creating a release..." - # VERSION=$(git rev-parse --short HEAD) - # gh release create "v1.0.0-$VERSION" ./artifacts/* --title "Release v0.10.4-$VERSION" --notes "Notarization succeeded. This is the release for version v1.0.0-$VERSION." + - name: Checkout Yao + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Setup Go Tools + run: make tools + + - name: Get Version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') + echo "version=${VERSION}" >> $GITHUB_OUTPUT + fi + + - name: Make Artifacts macOS + run: make artifacts-macos + env: + VERSION: ${{ steps.version.outputs.version }} + + - name: List Build Output + run: ls -lh dist/release/ + + - name: Install Certificates + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + mkdir -p certs + echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer + echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer + echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12 + security verify-cert -c certs/DeveloperIDG2CA.cer + security verify-cert -c certs/distribution.cer + + - name: Import Certificates + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign + security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign + security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign + security list-keychain -d user -s $KEYCHAIN_PATH + + - name: Sign Yao Binaries + run: | + VERSION="${{ steps.version.outputs.version }}" + IDENTITY="Developer ID Application: ${{ secrets.APPLE_SIGN }}" + for ARCH in arm64 amd64; do + BIN="dist/release/yao-${VERSION}-unstable-darwin-${ARCH}" + codesign --force --verbose --timestamp --options runtime --sign "$IDENTITY" "$BIN" + codesign --verify --deep --strict --verbose=2 "$BIN" + done + + - name: Prepare Output and Checksums + id: output + run: | + VERSION="${{ steps.version.outputs.version }}" + for ARCH in arm64 amd64; do + mkdir -p /tmp/yao-output-${ARCH} + cp "dist/release/yao-${VERSION}-unstable-darwin-${ARCH}" "/tmp/yao-output-${ARCH}/yao" + chmod +x "/tmp/yao-output-${ARCH}/yao" + done + + mkdir -p /tmp/checksums + shasum -a 256 /tmp/yao-output-arm64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-arm64.sha256 + shasum -a 256 /tmp/yao-output-amd64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-amd64.sha256 + echo "=== Checksums ===" + cat /tmp/checksums/*.sha256 + + - name: Upload arm64 Binary + uses: actions/upload-artifact@v4 + with: + name: yao-darwin-arm64 + path: /tmp/yao-output-arm64/yao + + - name: Upload amd64 Binary + uses: actions/upload-artifact@v4 + with: + name: yao-darwin-amd64 + path: /tmp/yao-output-amd64/yao + + - name: Upload arm64 Checksum + uses: actions/upload-artifact@v4 + with: + name: yao-darwin-arm64-sha256 + path: /tmp/checksums/yao-darwin-arm64.sha256 + + - name: Upload amd64 Checksum + uses: actions/upload-artifact@v4 + with: + name: yao-darwin-amd64-sha256 + path: /tmp/checksums/yao-darwin-amd64.sha256 + + # =================================================================== + # GitHub Release (if version provided) + # =================================================================== + release: + needs: build + runs-on: ubuntu-latest + if: github.event.inputs.version != '' + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Download arm64 Artifact + uses: actions/download-artifact@v4 + with: + name: yao-darwin-arm64 + path: dist/arm64 + + - name: Download amd64 Artifact + uses: actions/download-artifact@v4 + with: + name: yao-darwin-amd64 + path: dist/amd64 + + - name: Download Checksums + uses: actions/download-artifact@v4 + with: + pattern: yao-darwin-*-sha256 + path: dist/checksums + merge-multiple: true + + - name: Prepare Release Files + run: | + VERSION="${{ github.event.inputs.version }}" + mkdir -p release + cp dist/arm64/yao "release/yao-${VERSION}-darwin-arm64" + cp dist/amd64/yao "release/yao-${VERSION}-darwin-amd64" + cp dist/checksums/*.sha256 release/ + chmod +x release/yao-* + ls -la release/ + + - name: Create Tag + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag -a "v${{ github.event.inputs.version }}-macos" -m "Release v${{ github.event.inputs.version }} (macOS)" || true + git push origin "v${{ github.event.inputs.version }}-macos" || true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ github.event.inputs.version }}-macos + name: Yao v${{ github.event.inputs.version }} (macOS) + files: release/* + generate_release_notes: true + draft: true From d6e5afd26b6e71f5c621b995ff168bf73ce00d86 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 15 Mar 2026 17:47:07 +0800 Subject: [PATCH 9/9] refactor(workflow): consolidate build steps and remove redundant release processes - Merged the Linux build job into a single 'build' job for improved clarity and efficiency. - Removed redundant version retrieval steps from both Linux and macOS workflows, streamlining the release process. - Updated dependencies and artifact handling to ensure compatibility with the new workflow structure. - Enhanced the macOS workflow to trigger on version tags, improving version management during releases. Made-with: Cursor --- .github/workflows/release-linux.yml | 65 +--------------- .github/workflows/release-macos.yml | 63 ++-------------- .github/workflows/release.yml | 110 ++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index aa6f572a..27638bf0 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -19,9 +19,8 @@ env: jobs: # =================================================================== # Build Linux Binaries (amd64 + arm64) - # Uses the yaoapp/yao-build container which has all cross-compile deps. # =================================================================== - build-linux: + build: runs-on: ubuntu-latest container: image: yaoapp/yao-build:1.0.0 @@ -45,18 +44,6 @@ jobs: /app/build.sh ls -l /data - - name: Get Version - id: version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - else - VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') - echo "version=${VERSION}" >> $GITHUB_OUTPUT - fi - - name: Push To R2 run: | for file in /data/*; do @@ -70,59 +57,11 @@ jobs: name: yao-linux path: /data/* - # =================================================================== - # GitHub Release + Tag - # =================================================================== - release: - needs: build-linux - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Get Version - id: version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT - elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then - echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - else - VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') - echo "version=${VERSION}" >> $GITHUB_OUTPUT - fi - - - name: Download Linux Artifacts - uses: actions/download-artifact@v4 - with: - name: yao-linux - path: dist - - - name: List Artifacts - run: ls -lh dist/ - - - name: Create Tag (if manual dispatch) - if: github.event_name == 'workflow_dispatch' - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}" || true - git push origin "v${{ steps.version.outputs.version }}" || true - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.version }} - name: Yao v${{ steps.version.outputs.version }} - files: dist/* - generate_release_notes: true - # =================================================================== # Docker Images (multi-platform linux/amd64 + linux/arm64) - # Builds after R2 upload so Dockerfiles can curl binaries from R2. # =================================================================== docker: - needs: build-linux + needs: build runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 2229bc62..ec083f9c 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -6,6 +6,9 @@ on: version: description: "Release version (e.g. 1.0.0 or 1.0.0-alpha). Leave empty to read from share/const.go." required: false + push: + tags: + - "v*" permissions: contents: write @@ -107,6 +110,8 @@ jobs: run: | if [ -n "${{ github.event.inputs.version }}" ]; then echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT else VERSION=$(cat share/const.go | grep 'const VERSION' | awk '{print $4}' | sed 's/"//g') echo "version=${VERSION}" >> $GITHUB_OUTPUT @@ -155,7 +160,6 @@ jobs: done - name: Prepare Output and Checksums - id: output run: | VERSION="${{ steps.version.outputs.version }}" for ARCH in arm64 amd64; do @@ -167,7 +171,6 @@ jobs: mkdir -p /tmp/checksums shasum -a 256 /tmp/yao-output-arm64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-arm64.sha256 shasum -a 256 /tmp/yao-output-amd64/yao | awk '{print $1" yao"}' > /tmp/checksums/yao-darwin-amd64.sha256 - echo "=== Checksums ===" cat /tmp/checksums/*.sha256 - name: Upload arm64 Binary @@ -193,59 +196,3 @@ jobs: with: name: yao-darwin-amd64-sha256 path: /tmp/checksums/yao-darwin-amd64.sha256 - - # =================================================================== - # GitHub Release (if version provided) - # =================================================================== - release: - needs: build - runs-on: ubuntu-latest - if: github.event.inputs.version != '' - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Download arm64 Artifact - uses: actions/download-artifact@v4 - with: - name: yao-darwin-arm64 - path: dist/arm64 - - - name: Download amd64 Artifact - uses: actions/download-artifact@v4 - with: - name: yao-darwin-amd64 - path: dist/amd64 - - - name: Download Checksums - uses: actions/download-artifact@v4 - with: - pattern: yao-darwin-*-sha256 - path: dist/checksums - merge-multiple: true - - - name: Prepare Release Files - run: | - VERSION="${{ github.event.inputs.version }}" - mkdir -p release - cp dist/arm64/yao "release/yao-${VERSION}-darwin-arm64" - cp dist/amd64/yao "release/yao-${VERSION}-darwin-amd64" - cp dist/checksums/*.sha256 release/ - chmod +x release/yao-* - ls -la release/ - - - name: Create Tag - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git tag -a "v${{ github.event.inputs.version }}-macos" -m "Release v${{ github.event.inputs.version }} (macOS)" || true - git push origin "v${{ github.event.inputs.version }}-macos" || true - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ github.event.inputs.version }}-macos - name: Yao v${{ github.event.inputs.version }} (macOS) - files: release/* - generate_release_notes: true - draft: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..719545a6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +name: Release + +on: + workflow_run: + workflows: ["Release Linux", "Release macOS"] + types: + - completed + +permissions: + contents: write + +jobs: + # =================================================================== + # Wait for both workflows to succeed, then create a unified release + # =================================================================== + release: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'v') + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Get Version + id: version + run: | + TAG="${{ github.event.workflow_run.head_branch }}" + echo "version=${TAG#v}" >> $GITHUB_OUTPUT + echo "tag=${TAG}" >> $GITHUB_OUTPUT + + - name: Wait for Both Workflows + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + echo "Waiting for both Release Linux and Release macOS to complete for $TAG..." + + for i in $(seq 1 60); do + LINUX_STATUS=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"') + MACOS_STATUS=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"') + + echo "Attempt $i: Linux=$LINUX_STATUS macOS=$MACOS_STATUS" + + if [ "$LINUX_STATUS" = "success" ] && [ "$MACOS_STATUS" = "success" ]; then + echo "Both workflows completed successfully." + exit 0 + fi + + if [ "$LINUX_STATUS" = "failure" ] || [ "$MACOS_STATUS" = "failure" ]; then + echo "::error::One or both workflows failed (Linux=$LINUX_STATUS macOS=$MACOS_STATUS)" + exit 1 + fi + + sleep 60 + done + + echo "::error::Timed out waiting for workflows" + exit 1 + + - name: Download Linux Artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + LINUX_RUN_ID=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId') + mkdir -p dist/linux + gh run download "$LINUX_RUN_ID" --name yao-linux --dir dist/linux + + - name: Download macOS Artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + MACOS_RUN_ID=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId') + mkdir -p dist/macos + gh run download "$MACOS_RUN_ID" --name yao-darwin-arm64 --dir dist/macos + gh run download "$MACOS_RUN_ID" --name yao-darwin-amd64 --dir dist/macos + gh run download "$MACOS_RUN_ID" --pattern "yao-darwin-*-sha256" --dir dist/macos + + - name: Prepare Release Files + run: | + VERSION="${{ steps.version.outputs.version }}" + mkdir -p release + + cp dist/linux/* release/ 2>/dev/null || true + + if [ -f dist/macos/yao-darwin-arm64/yao ]; then + cp dist/macos/yao-darwin-arm64/yao "release/yao-${VERSION}-darwin-arm64" + elif [ -f dist/macos/yao ]; then + cp dist/macos/yao "release/yao-${VERSION}-darwin-arm64" + fi + + if [ -f dist/macos/yao-darwin-amd64/yao ]; then + cp dist/macos/yao-darwin-amd64/yao "release/yao-${VERSION}-darwin-amd64" + fi + + find dist/macos -name "*.sha256" -exec cp {} release/ \; + + chmod +x release/yao-* 2>/dev/null || true + echo "=== Release files ===" + ls -lh release/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: Yao v${{ steps.version.outputs.version }} + files: release/* + generate_release_notes: true