Files
goclaw/internal/upgrade/hook_workspace_normalize.go
Duy /zuey/ d4463cde6f fix(exec): surface real cwd error + auto-normalize stale workspace paths (#77)
* 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).
2026-05-27 11:52:25 +07:00

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, &current); 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(), "/")
}