Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil Sonti
162e78ce78 fix(alpha): address log review feedback 2026-04-27 15:45:52 -07:00
Nikhil Sonti
2feac502af fix(alpha): write balpha process logs 2026-04-27 15:44:33 -07:00
12 changed files with 455 additions and 14 deletions

View File

@@ -92,6 +92,7 @@ Each start:
- Runs `tools/dev/setup.sh`.
- Builds the WXT extension.
- Starts BrowserOS and the local Bun server.
- Tees BrowserOS and server output to log files under the copied profile.
Use this when you want to refresh the copied profile before launching:
@@ -107,6 +108,28 @@ balpha start --headless
Stop the environment with `Ctrl+C`.
## Logs
`balpha start` writes process logs to:
```text
~/.config/balpha/profile/logs
```
The current files are:
- `chromium.log`: BrowserOS/Chromium stdout and stderr.
- `server.log`: local Bun server stdout and stderr.
When either file is older than one day at startup, `balpha` rotates it to
`<name>.old` before writing a fresh log.
To print the log directory and file paths:
```bash
balpha logs
```
## Update The Checkout
`balpha start` intentionally does not pull. To update the configured repo:

View File

@@ -24,6 +24,7 @@ func BuildArgs(cfg ArgsConfig) []string {
"--show-component-extension-options",
"--disable-browseros-server",
"--disable-browseros-extensions",
"--enable-logging=stderr",
fmt.Sprintf("--remote-debugging-port=%d", cfg.Ports.CDP),
// Keep all server aliases until installed BrowserOS apps converge on one switch.
fmt.Sprintf("--browseros-mcp-port=%d", cfg.Ports.Server),

View File

@@ -26,6 +26,7 @@ func TestBuildArgs(t *testing.T) {
"--profile-directory=Default",
"--disable-browseros-server",
"--disable-browseros-extensions",
"--enable-logging=stderr",
"--load-extension=/repo/packages/browseros-agent/apps/agent/dist/chrome-mv3-dev",
"chrome://newtab",
} {

View File

@@ -0,0 +1,44 @@
package cmd
import (
"fmt"
"io"
"browseros-alpha/config"
"browseros-alpha/proc"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(logsCmd)
}
var logsCmd = &cobra.Command{
Use: "logs",
Short: "Print balpha log files",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfigWithoutValidation()
if err != nil {
return err
}
return printLogs(cmd.OutOrStdout(), cfg)
},
}
func printLogs(out io.Writer, cfg config.Config) error {
logDir := cfg.LogDir()
fmt.Fprintf(out, "Log directory: %s\n", logDir)
files, err := proc.ListLogFiles(logDir)
if err != nil {
return err
}
if len(files) == 0 {
fmt.Fprintln(out, "No log files found.")
return nil
}
for _, file := range files {
fmt.Fprintf(out, "%s (%d bytes, modified %s)\n", file.Path, file.Size, file.ModTime.Format("2006-01-02 15:04:05"))
}
return nil
}

View File

@@ -0,0 +1,55 @@
package cmd
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"browseros-alpha/config"
)
func TestPrintLogsShowsDirectoryAndFiles(t *testing.T) {
devDir := t.TempDir()
cfg := config.Config{DevUserDataDir: devDir}
logDir := cfg.LogDir()
if err := os.MkdirAll(logDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"server.log", "chromium.log"} {
if err := os.WriteFile(filepath.Join(logDir, name), []byte("log"), 0644); err != nil {
t.Fatal(err)
}
}
var out bytes.Buffer
if err := printLogs(&out, cfg); err != nil {
t.Fatal(err)
}
got := out.String()
for _, want := range []string{
"Log directory: " + logDir,
filepath.Join(logDir, "chromium.log"),
filepath.Join(logDir, "server.log"),
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in\n%s", want, got)
}
}
}
func TestPrintLogsHandlesMissingDirectory(t *testing.T) {
cfg := config.Config{DevUserDataDir: t.TempDir()}
var out bytes.Buffer
if err := printLogs(&out, cfg); err != nil {
t.Fatal(err)
}
got := out.String()
if !strings.Contains(got, "No log files found.") {
t.Fatalf("unexpected output:\n%s", got)
}
}

View File

@@ -35,6 +35,17 @@ var refreshProfileCmd = &cobra.Command{
}
func loadConfig() (config.Config, error) {
cfg, err := loadConfigWithoutValidation()
if err != nil {
return config.Config{}, err
}
if err := cfg.Validate(); err != nil {
return config.Config{}, err
}
return cfg, nil
}
func loadConfigWithoutValidation() (config.Config, error) {
path, err := config.Path()
if err != nil {
return config.Config{}, err
@@ -43,8 +54,5 @@ func loadConfig() (config.Config, error) {
if err != nil {
return config.Config{}, fmt.Errorf("missing config at %s; run balpha init: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return config.Config{}, err
}
return cfg, nil
}

View File

@@ -22,6 +22,11 @@ import (
var startRefreshProfile bool
var startHeadless bool
const (
serverLogName = "server.log"
chromiumLogName = "chromium.log"
)
func init() {
startCmd.Flags().BoolVar(&startRefreshProfile, "refresh-profile", false, "Refresh copied BrowserOS profile before launch")
startCmd.Flags().BoolVar(&startHeadless, "headless", false, "Run BrowserOS headless")
@@ -84,12 +89,17 @@ func runEnvironment(cfg config.Config, agentRoot string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := os.MkdirAll(cfg.LogDir(), 0755); err != nil {
return err
}
var wg sync.WaitGroup
var managed []*proc.ManagedProc
managed = append(managed, proc.StartManaged(ctx, &wg, proc.ProcConfig{
Tag: proc.TagBrowser,
Dir: agentRoot,
Restart: false,
LogPath: cfg.LogPath(chromiumLogName),
Cmd: browser.BuildArgs(browser.ArgsConfig{
Binary: cfg.BrowserOSAppPath,
AgentRoot: agentRoot,
@@ -119,6 +129,7 @@ func runEnvironment(cfg config.Config, agentRoot string) error {
Dir: serverDir,
Env: env,
Restart: true,
LogPath: cfg.LogPath(serverLogName),
Cmd: serverCommand(),
}))
printSummary(cfg, agentRoot)
@@ -172,6 +183,7 @@ func printSummary(cfg config.Config, agentRoot string) {
proc.LogMsgf(proc.TagInfo, "Repo: %s", cfg.RepoPath)
proc.LogMsgf(proc.TagInfo, "Agent root: %s", agentRoot)
proc.LogMsgf(proc.TagInfo, "Profile: %s", cfg.DevUserDataDir)
proc.LogMsgf(proc.TagInfo, "Logs: %s", cfg.LogDir())
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", cfg.Ports.CDP, cfg.Ports.Server, cfg.Ports.Extension)
fmt.Println()
}

View File

@@ -38,6 +38,8 @@ type packageJSON struct {
Name string `json:"name"`
}
const LogDirName = "logs"
func Path() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -127,6 +129,14 @@ func (c Config) DevProfilePath() string {
return filepath.Join(c.DevUserDataDir, c.DevProfileDir)
}
func (c Config) LogDir() string {
return filepath.Join(c.DevUserDataDir, LogDirName)
}
func (c Config) LogPath(name string) string {
return filepath.Join(c.LogDir(), name)
}
func (c Config) Validate() error {
if c.RepoPath == "" {
return fmt.Errorf("repo_path is required")

View File

@@ -20,6 +20,9 @@ func TestDefaults(t *testing.T) {
if cfg.DevUserDataDir != filepath.Join(home, ".config/balpha/profile") {
t.Fatalf("unexpected dev dir: %s", cfg.DevUserDataDir)
}
if cfg.LogDir() != filepath.Join(home, ".config/balpha/profile/logs") {
t.Fatalf("unexpected log dir: %s", cfg.LogDir())
}
if cfg.DevProfileDir != "Default" {
t.Fatalf("unexpected dev profile: %s", cfg.DevProfileDir)
}
@@ -40,6 +43,15 @@ func TestDefaults(t *testing.T) {
}
}
func TestLogPathUsesProfileLogDir(t *testing.T) {
cfg := Config{DevUserDataDir: "/tmp/balpha-profile"}
got := cfg.LogPath("server.log")
want := filepath.Join("/tmp/balpha-profile", "logs", "server.log")
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")

View File

@@ -3,15 +3,32 @@ package proc
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/fatih/color"
)
const LogMaxAge = 24 * time.Hour
type Tag struct {
Name string
Color *color.Color
}
type LogFile struct {
Name string
Path string
Size int64
ModTime time.Time
}
var (
TagBuild = Tag{"build", color.New(color.FgYellow)}
TagAgent = Tag{"agent", color.New(color.FgMagenta)}
@@ -24,23 +41,115 @@ var (
WarnColor = color.New(color.FgYellow)
BoldColor = color.New(color.Bold)
DimColor = color.New(color.Faint)
ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
)
func LogMsg(t Tag, msg string) {
fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg)
logMsg(t, msg, os.Stdout, nil, nil)
}
func LogMsgf(t Tag, format string, args ...any) {
LogMsg(t, fmt.Sprintf(format, args...))
}
func StreamLines(r interface{ Read([]byte) (int, error) }, t Tag) {
func LogMsgTee(t Tag, msg string, file io.Writer, fileMu *sync.Mutex) {
logMsg(t, msg, os.Stdout, file, fileMu)
}
func StreamLines(r io.Reader, t Tag) {
streamLines(r, t, os.Stdout, nil, nil)
}
func OpenLogFile(logDir string, name string, now time.Time) (*os.File, string, error) {
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, "", err
}
path := filepath.Join(logDir, name)
if err := rotateLogIfNeeded(path, now); err != nil {
return nil, "", err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, "", err
}
return file, path, nil
}
func ListLogFiles(logDir string) ([]LogFile, error) {
entries, err := os.ReadDir(logDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
files := []LogFile{}
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".log") && !strings.HasSuffix(entry.Name(), ".log.old") {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
files = append(files, LogFile{
Name: entry.Name(),
Path: filepath.Join(logDir, entry.Name()),
Size: info.Size(),
ModTime: info.ModTime(),
})
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name < files[j].Name
})
return files, nil
}
func rotateLogIfNeeded(logPath string, now time.Time) error {
info, err := os.Stat(logPath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if now.Sub(info.ModTime()) <= LogMaxAge {
return nil
}
backupPath := logPath + ".old"
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return err
}
return os.Rename(logPath, backupPath)
}
func streamLines(r io.Reader, t Tag, terminal io.Writer, file io.Writer, fileMu *sync.Mutex) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), line)
logMsg(t, line, terminal, file, fileMu)
}
}
if err := scanner.Err(); err != nil {
logMsg(t, fmt.Sprintf("log stream error: %v", err), terminal, file, fileMu)
}
}
func logMsg(t Tag, msg string, terminal io.Writer, file io.Writer, fileMu *sync.Mutex) {
if fileMu != nil {
fileMu.Lock()
defer fileMu.Unlock()
}
fmt.Fprintf(terminal, "%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg)
if file == nil {
return
}
fmt.Fprintf(file, "[%s] %s\n", t.Name, ansiPattern.ReplaceAllString(msg, ""))
}

View File

@@ -0,0 +1,147 @@
package proc
import (
"bytes"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestOpenLogFileRotatesFileOlderThanOneDay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.log")
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
oldTime := now.Add(-25 * time.Hour)
if err := os.Chtimes(path, oldTime, oldTime); err != nil {
t.Fatal(err)
}
file, gotPath, err := OpenLogFile(dir, "server.log", now)
if err != nil {
t.Fatal(err)
}
if gotPath != path {
t.Fatalf("got %q want %q", gotPath, path)
}
if _, err := file.WriteString("new\n"); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
rotated, err := os.ReadFile(path + ".old")
if err != nil {
t.Fatal(err)
}
if string(rotated) != "old\n" {
t.Fatalf("rotated content = %q", rotated)
}
current, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(current) != "new\n" {
t.Fatalf("current content = %q", current)
}
}
func TestOpenLogFileAppendsFreshFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "chromium.log")
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
fresh := now.Add(-time.Hour)
if err := os.Chtimes(path, fresh, fresh); err != nil {
t.Fatal(err)
}
file, _, err := OpenLogFile(dir, "chromium.log", now)
if err != nil {
t.Fatal(err)
}
if _, err := file.WriteString("new\n"); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path + ".old"); !os.IsNotExist(err) {
t.Fatalf("unexpected rotated file, stat err=%v", err)
}
current, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(current) != "old\nnew\n" {
t.Fatalf("current content = %q", current)
}
}
func TestListLogFilesReturnsRegularFilesSortedByName(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"server.log", "chromium.log.old", "chromium.log", "server.log.backup"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0644); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(dir, "nested.log"), 0755); err != nil {
t.Fatal(err)
}
files, err := ListLogFiles(dir)
if err != nil {
t.Fatal(err)
}
got := []string{}
for _, file := range files {
got = append(got, file.Name)
}
want := []string{"chromium.log", "chromium.log.old", "server.log"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestStreamLinesWritesTerminalAndFile(t *testing.T) {
var terminal bytes.Buffer
var file bytes.Buffer
var mu sync.Mutex
streamLines(strings.NewReader("first\nsecond\n"), TagServer, &terminal, &file, &mu)
terminalOutput := terminal.String()
if !strings.Contains(terminalOutput, "[server] first") || !strings.Contains(terminalOutput, "[server] second") {
t.Fatalf("unexpected terminal output: %q", terminalOutput)
}
fileOutput := file.String()
if fileOutput != "[server] first\n[server] second\n" {
t.Fatalf("unexpected file output: %q", fileOutput)
}
}
func TestStreamLinesLogsScannerErrors(t *testing.T) {
var terminal bytes.Buffer
var file bytes.Buffer
var mu sync.Mutex
longLine := strings.Repeat("x", 1024*1024+1)
streamLines(strings.NewReader(longLine), TagBrowser, &terminal, &file, &mu)
for name, got := range map[string]string{
"terminal": terminal.String(),
"file": file.String(),
} {
if !strings.Contains(got, "log stream error: bufio.Scanner: token too long") {
t.Fatalf("%s output missing scanner error: %q", name, got)
}
}
}

View File

@@ -2,8 +2,10 @@ package proc
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
@@ -17,6 +19,7 @@ type ProcConfig struct {
Restart bool
Cmd []string
BeforeStart func() error
LogPath string
}
type ManagedProc struct {
@@ -45,6 +48,22 @@ func StartManaged(ctx context.Context, wg *sync.WaitGroup, cfg ProcConfig) *Mana
}
func (mp *ManagedProc) run(ctx context.Context) {
var logFile *os.File
var logMu sync.Mutex
if mp.Cfg.LogPath != "" {
file, path, err := OpenLogFile(filepath.Dir(mp.Cfg.LogPath), filepath.Base(mp.Cfg.LogPath), time.Now())
if err != nil {
LogMsg(mp.Cfg.Tag, WarnColor.Sprintf("File logging disabled: %v", err))
} else {
logFile = file
defer logFile.Close()
LogMsgTee(mp.Cfg.Tag, "Writing log file: "+path, logFile, &logMu)
}
}
log := func(msg string) {
LogMsgTee(mp.Cfg.Tag, msg, logFile, &logMu)
}
for {
if ctx.Err() != nil {
return
@@ -52,7 +71,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
if mp.Cfg.BeforeStart != nil {
if err := mp.Cfg.BeforeStart(); err != nil {
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
log(ErrorColor.Sprintf("Pre-start failed: %v", err))
if !mp.Cfg.Restart || ctx.Err() != nil {
return
}
@@ -61,7 +80,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
}
}
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
log(fmt.Sprintf("Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " "))))
cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...)
cmd.Dir = mp.Cfg.Dir
@@ -74,7 +93,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
stderr, _ := cmd.StderrPipe()
if err := cmd.Start(); err != nil {
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Error starting: %v", err))
log(ErrorColor.Sprintf("Error starting: %v", err))
if !mp.Cfg.Restart || ctx.Err() != nil {
return
}
@@ -94,8 +113,8 @@ func (mp *ManagedProc) run(ctx context.Context) {
var streamWg sync.WaitGroup
streamWg.Add(2)
go func() { defer streamWg.Done(); StreamLines(stdout, mp.Cfg.Tag) }()
go func() { defer streamWg.Done(); StreamLines(stderr, mp.Cfg.Tag) }()
go func() { defer streamWg.Done(); streamLines(stdout, mp.Cfg.Tag, os.Stdout, logFile, &logMu) }()
go func() { defer streamWg.Done(); streamLines(stderr, mp.Cfg.Tag, os.Stdout, logFile, &logMu) }()
streamWg.Wait()
_ = cmd.Wait()
@@ -111,16 +130,16 @@ func (mp *ManagedProc) run(ctx context.Context) {
exitCode := cmd.ProcessState.ExitCode()
if exitCode != 0 {
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Process exited with code %d", exitCode))
log(ErrorColor.Sprintf("Process exited with code %d", exitCode))
} else {
LogMsg(mp.Cfg.Tag, "Process exited cleanly")
log("Process exited cleanly")
}
if !mp.Cfg.Restart {
return
}
LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Restarting in 1s..."))
log(WarnColor.Sprint("Restarting in 1s..."))
select {
case <-ctx.Done():
return