feat(symlinks): add shared-state symlink manager (A1)
Adds internal/symlinks package that encodes in code the convention previously maintained by hand on the VM: every Claude account home must expose `session-env`, `file-history` and `projects` as symlinks to a single shared target, so account failover does not create divergent state (duplicate JSONL transcripts, broken undo history). - EnsureForAccount(home, required) creates missing links and target directories, refuses to auto-correct a divergent link (risks data loss), and errors when a regular file sits where the link belongs. - ValidateAll(homes, required) aggregates errors across both accounts so the operator sees every problem at once rather than fixing one per restart cycle. - RequiredShared exposes the production defaults so lifecycle and switcher (A2/A3) can depend on it directly. 9/9 unit tests green. Part of Phase 1 Chantier A — Failover robuste.
This commit is contained in:
parent
4cbdcf143a
commit
91091d7abf
4 changed files with 414 additions and 4 deletions
165
internal/symlinks/shared.go
Normal file
165
internal/symlinks/shared.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Package symlinks manages the shared-state symlinks that every Claude
|
||||
// account home must expose, so that account failover does not create state
|
||||
// divergence (duplicated JSONL transcripts, broken undo history, drifted
|
||||
// session env).
|
||||
//
|
||||
// Rationale
|
||||
//
|
||||
// Claude Code stores three directories whose content MUST be identical
|
||||
// across the two configured accounts for failover to be a no-op:
|
||||
//
|
||||
// - projects/ — session JSONL transcripts (used by `claude --resume`)
|
||||
// - session-env/ — per-session environment and working-dir metadata
|
||||
// - file-history/ — undo/redo history persistence
|
||||
//
|
||||
// If account A writes under `~/.claude-compte1/projects/...` while account
|
||||
// B later runs under `~/.claude-compte2/projects/...`, resume fails
|
||||
// silently with "session not found" and the operator loses every in-flight
|
||||
// conversation.
|
||||
//
|
||||
// Historically we fixed this by creating symlinks manually on the
|
||||
// operator's VM. Any fresh deployment that forgets those links silently
|
||||
// reintroduces the bug. This package encodes the convention in code:
|
||||
// EnsureForAccount creates missing links, ValidateAll fails fast at
|
||||
// startup when an account home is misconfigured.
|
||||
package symlinks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultSharedRoot is the directory under which the three shared targets
|
||||
// live. All SharedSymlink.Target values default to a subdirectory of this
|
||||
// root so tests can override the root without rewriting the shared list.
|
||||
const DefaultSharedRoot = "/home/ubuntu"
|
||||
|
||||
// SharedSymlink describes one required link inside a Claude account home.
|
||||
//
|
||||
// Target is the absolute path on disk that holds the real shared
|
||||
// directory. Name is the basename of the link that must exist inside each
|
||||
// account home (e.g. `session-env`, `file-history`, `projects`).
|
||||
type SharedSymlink struct {
|
||||
Target string
|
||||
Name string
|
||||
}
|
||||
|
||||
// RequiredShared lists the three symlinks every Claude account home must
|
||||
// expose. The list is package-level so integration tests can read it, but
|
||||
// callers SHOULD prefer the EnsureForAccount / ValidateAll entry points
|
||||
// that accept an override list for isolation.
|
||||
var RequiredShared = []SharedSymlink{
|
||||
{Target: "/home/ubuntu/.claude-session-env-shared", Name: "session-env"},
|
||||
{Target: "/home/ubuntu/.claude-file-history-shared", Name: "file-history"},
|
||||
{Target: "/home/ubuntu/.claude-projects-shared", Name: "projects"},
|
||||
}
|
||||
|
||||
// EnsureForAccount verifies (and creates if missing) every required shared
|
||||
// symlink for a single account home. Behaviour:
|
||||
//
|
||||
// - If accountHome does not exist, it is created (mode 0700).
|
||||
// - If Target (the shared destination) does not exist, it is created
|
||||
// (mode 0700). Both accounts pointing at a non-existent target would
|
||||
// produce two separate state trees on first write.
|
||||
// - If the link is absent, it is created.
|
||||
// - If the link is present and points at Target, nothing happens.
|
||||
// - If the link is present but points elsewhere, an error is returned.
|
||||
// We REFUSE to auto-correct a divergent link because fixing it blindly
|
||||
// could delete user data: the "wrong" target may contain the only copy
|
||||
// of the session transcripts.
|
||||
// - If a regular file or directory exists where the link should be,
|
||||
// an error is returned for the same reason.
|
||||
func EnsureForAccount(accountHome string, required []SharedSymlink) error {
|
||||
if accountHome == "" {
|
||||
return errors.New("symlinks: accountHome is empty")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(accountHome, 0700); err != nil {
|
||||
return fmt.Errorf("symlinks: create account home %q: %w", accountHome, err)
|
||||
}
|
||||
|
||||
for _, sl := range required {
|
||||
if err := ensureTarget(sl.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureLink(accountHome, sl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAll runs EnsureForAccount on every account home. It aggregates
|
||||
// all errors and returns a single error with every failure inlined, so the
|
||||
// operator sees the full picture at startup rather than fixing one link,
|
||||
// restarting, hitting the next one, repeat.
|
||||
func ValidateAll(accountHomes []string, required []SharedSymlink) error {
|
||||
if len(accountHomes) == 0 {
|
||||
return errors.New("symlinks: no account homes provided")
|
||||
}
|
||||
var errs []string
|
||||
for _, home := range accountHomes {
|
||||
if err := EnsureForAccount(home, required); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("symlinks: validation failed for %d account home(s): %s",
|
||||
len(errs), strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTarget creates Target as an empty directory when absent.
|
||||
// An existing file (non-directory, non-symlink) at Target is an operator
|
||||
// error we cannot resolve automatically.
|
||||
func ensureTarget(target string) error {
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("symlinks: stat shared target %q: %w", target, err)
|
||||
}
|
||||
if mkErr := os.MkdirAll(target, 0700); mkErr != nil {
|
||||
return fmt.Errorf("symlinks: create shared target %q: %w", target, mkErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("symlinks: shared target %q is not a directory", target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureLink reconciles one link entry inside accountHome.
|
||||
func ensureLink(accountHome string, sl SharedSymlink) error {
|
||||
linkPath := filepath.Join(accountHome, sl.Name)
|
||||
|
||||
info, err := os.Lstat(linkPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if linkErr := os.Symlink(sl.Target, linkPath); linkErr != nil {
|
||||
return fmt.Errorf("symlinks: create %q → %q: %w", linkPath, sl.Target, linkErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("symlinks: lstat %q: %w", linkPath, err)
|
||||
}
|
||||
|
||||
// Path exists — must be a symlink pointing at Target.
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
return fmt.Errorf("symlinks: %q exists but is not a symlink (expected → %q)",
|
||||
linkPath, sl.Target)
|
||||
}
|
||||
currentTarget, err := os.Readlink(linkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("symlinks: readlink %q: %w", linkPath, err)
|
||||
}
|
||||
if currentTarget != sl.Target {
|
||||
return fmt.Errorf("symlinks: divergent link at %q: points to %q, expected %q (refusing to auto-correct to avoid data loss)",
|
||||
linkPath, currentTarget, sl.Target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue