mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-13 03:05:12 +00:00
d4463cde6f
* fix(exec): surface chdir error instead of misleading fork/exec Linux Go forkAndExecInChild conflates chdir + execve failures into one PathError naming only argv0. When agent workspace points to a stale directory, exec reports "fork/exec /usr/bin/gh: no such file" even though the binary exists — the real failure is chdir on cmd.Dir. Two fixes: - loop_context.go: fall back to l.workspace when per-user MkdirAll fails (3 sites: user workspace, dispatched team, auto-resolved team) - shell.go + credentialed_exec.go: preflight cwd via validateExecCwd before exec.Command, so the error names the directory, not the binary Regression test TestValidateExecCwd covers empty / existing / missing / file-instead-of-dir. * feat(upgrade): auto-normalize stale agent workspace paths Detects non-portable workspace values left by deployment migrations (Docker → bare-metal, host path drift) and rewrites them to the current configured base. Registered as data hook 072_normalize_agent_workspaces; runs automatically after `migrate up`. Stale patterns: - /app/workspace/* (Docker container path persisted on bare-metal) - ~ prefix (Go doesn't expand tildes; MkdirAll creates literal ~ dir) Base resolved with gateway precedence: GOCLAW_WORKSPACE env > config.Agents.Defaults.Workspace > default ~/.goclaw/workspace. Idempotent (skips rows already at proposed value). Conservative (leaves absolute non-stale paths untouched as intentional custom config).
127 lines
3.7 KiB
Go
127 lines
3.7 KiB
Go
package upgrade
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
)
|
|
|
|
const workspaceNormalizeHookName = "072_normalize_agent_workspaces"
|
|
|
|
// normalizeAgentWorkspaces rewrites stale `agents.workspace` values that
|
|
// won't resolve on the current host. Triggered automatically when the
|
|
// deployment backend changes shape (Docker → bare-metal, host path drift).
|
|
//
|
|
// A workspace is considered stale when it is non-portable on the new host:
|
|
// - prefixed with `/app/workspace/` (Docker container path persisted across
|
|
// a Docker → bare-metal migration; `/app` does not exist on the host)
|
|
// - prefixed with `~` (Go does not expand tildes — Mkdir would create a
|
|
// literal `~` directory in cwd)
|
|
//
|
|
// Stale rows are rewritten to `{configured_base}/{agent_key}`, where the
|
|
// base is read with the same precedence used by the gateway:
|
|
// `GOCLAW_WORKSPACE` env > config file `Agents.Defaults.Workspace` >
|
|
// default `~/.goclaw/workspace` (with tilde expansion).
|
|
//
|
|
// Idempotent: rows already at the proposed value are skipped.
|
|
// Conservative: absolute, non-stale paths are left untouched so operators
|
|
// who intentionally placed an agent in a custom directory keep their config.
|
|
func normalizeAgentWorkspaces(ctx context.Context, db *sql.DB) error {
|
|
base := resolveWorkspaceBase()
|
|
if base == "" {
|
|
slog.Warn("workspace normalization: no base configured, skipping")
|
|
return nil
|
|
}
|
|
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT id, agent_key, workspace FROM agents WHERE deleted_at IS NULL`)
|
|
if err != nil {
|
|
return fmt.Errorf("query agents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type pending struct {
|
|
id, key, current, proposed string
|
|
}
|
|
var todo []pending
|
|
for rows.Next() {
|
|
var id, key, current string
|
|
if err := rows.Scan(&id, &key, ¤t); err != nil {
|
|
return fmt.Errorf("scan agent row: %w", err)
|
|
}
|
|
if !isStaleWorkspace(current) {
|
|
continue
|
|
}
|
|
proposed := filepath.Join(base, key)
|
|
if current == proposed {
|
|
continue
|
|
}
|
|
todo = append(todo, pending{id, key, current, proposed})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate agent rows: %w", err)
|
|
}
|
|
|
|
for _, p := range todo {
|
|
if _, err := db.ExecContext(ctx,
|
|
`UPDATE agents SET workspace = $1, updated_at = NOW() WHERE id = $2`,
|
|
p.proposed, p.id,
|
|
); err != nil {
|
|
return fmt.Errorf("update agent %q: %w", p.key, err)
|
|
}
|
|
slog.Info("workspace normalized",
|
|
"agent_key", p.key,
|
|
"from", p.current,
|
|
"to", p.proposed,
|
|
)
|
|
}
|
|
|
|
slog.Info("workspace normalization complete",
|
|
"rewritten", len(todo),
|
|
"base", base,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// isStaleWorkspace reports whether the stored path is non-portable on the
|
|
// current host. See normalizeAgentWorkspaces for the full set of patterns.
|
|
func isStaleWorkspace(ws string) bool {
|
|
ws = strings.TrimSpace(ws)
|
|
if ws == "" {
|
|
return false
|
|
}
|
|
if ws == "/app/workspace" || strings.HasPrefix(ws, "/app/workspace/") {
|
|
return true
|
|
}
|
|
if strings.HasPrefix(ws, "~") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveWorkspaceBase returns the configured workspace base directory.
|
|
// Matches the gateway's precedence so the hook normalizes to whatever the
|
|
// next startup will use.
|
|
func resolveWorkspaceBase() string {
|
|
if v := strings.TrimSpace(os.Getenv("GOCLAW_WORKSPACE")); v != "" {
|
|
return strings.TrimRight(config.ExpandHome(v), "/")
|
|
}
|
|
cfgPath := os.Getenv("GOCLAW_CONFIG")
|
|
if cfgPath == "" {
|
|
cfgPath = "config.json"
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
slog.Warn("workspace normalization: load config failed, using default",
|
|
"path", cfgPath, "error", err)
|
|
return strings.TrimRight(config.ExpandHome("~/.goclaw/workspace"), "/")
|
|
}
|
|
return strings.TrimRight(cfg.WorkspacePath(), "/")
|
|
}
|