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:
Ubuntu 2026-04-16 18:55:32 +00:00
parent 4cbdcf143a
commit 91091d7abf
4 changed files with 414 additions and 4 deletions

View file

@ -0,0 +1,206 @@
package symlinks
import (
"os"
"path/filepath"
"strings"
"testing"
)
// testRequired returns a SharedSymlink list whose Targets live entirely
// under tmpDir, so the tests never touch the operator's real home.
func testRequired(tmpDir string) []SharedSymlink {
return []SharedSymlink{
{Target: filepath.Join(tmpDir, "session-env-shared"), Name: "session-env"},
{Target: filepath.Join(tmpDir, "file-history-shared"), Name: "file-history"},
{Target: filepath.Join(tmpDir, "projects-shared"), Name: "projects"},
}
}
func TestEnsureForAccount_missingCreatesLinksAndTargets(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "account1")
req := testRequired(tmp)
if err := EnsureForAccount(home, req); err != nil {
t.Fatalf("EnsureForAccount: %v", err)
}
for _, sl := range req {
linkPath := filepath.Join(home, sl.Name)
info, err := os.Lstat(linkPath)
if err != nil {
t.Errorf("expected link at %s: %v", linkPath, err)
continue
}
if info.Mode()&os.ModeSymlink == 0 {
t.Errorf("%s exists but is not a symlink", linkPath)
}
got, err := os.Readlink(linkPath)
if err != nil {
t.Errorf("readlink %s: %v", linkPath, err)
continue
}
if got != sl.Target {
t.Errorf("link %s points to %q, want %q", linkPath, got, sl.Target)
}
// Target directory must exist too.
if st, err := os.Stat(sl.Target); err != nil || !st.IsDir() {
t.Errorf("target %s should be a directory, err=%v", sl.Target, err)
}
}
}
func TestEnsureForAccount_idempotent(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "account1")
req := testRequired(tmp)
if err := EnsureForAccount(home, req); err != nil {
t.Fatalf("first pass: %v", err)
}
if err := EnsureForAccount(home, req); err != nil {
t.Fatalf("second pass should be a no-op, got: %v", err)
}
}
func TestEnsureForAccount_divergentLinkReturnsError(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "account1")
req := testRequired(tmp)
// Pre-create a wrong symlink for "projects".
if err := os.MkdirAll(home, 0700); err != nil {
t.Fatalf("mkdir home: %v", err)
}
wrongTarget := filepath.Join(tmp, "someone-elses-dir")
if err := os.MkdirAll(wrongTarget, 0700); err != nil {
t.Fatalf("mkdir wrong target: %v", err)
}
linkPath := filepath.Join(home, "projects")
if err := os.Symlink(wrongTarget, linkPath); err != nil {
t.Fatalf("seed wrong symlink: %v", err)
}
err := EnsureForAccount(home, req)
if err == nil {
t.Fatal("expected error for divergent link, got nil")
}
if !strings.Contains(err.Error(), "divergent") {
t.Errorf("error should mention 'divergent': %v", err)
}
// The wrong symlink MUST be preserved (no auto-correction).
got, err := os.Readlink(linkPath)
if err != nil {
t.Fatalf("readlink after error: %v", err)
}
if got != wrongTarget {
t.Errorf("divergent link was mutated: now %q, want preserved %q", got, wrongTarget)
}
}
func TestEnsureForAccount_regularFileInsteadOfLinkFails(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "account1")
req := testRequired(tmp)
if err := os.MkdirAll(home, 0700); err != nil {
t.Fatalf("mkdir home: %v", err)
}
// Create a regular file at the session-env path.
bogus := filepath.Join(home, "session-env")
if err := os.WriteFile(bogus, []byte("oops"), 0600); err != nil {
t.Fatalf("seed regular file: %v", err)
}
err := EnsureForAccount(home, req)
if err == nil {
t.Fatal("expected error for regular-file-at-link-path, got nil")
}
if !strings.Contains(err.Error(), "not a symlink") {
t.Errorf("error should mention 'not a symlink': %v", err)
}
}
func TestEnsureForAccount_emptyHomeReturnsError(t *testing.T) {
if err := EnsureForAccount("", nil); err == nil {
t.Fatal("expected error for empty home, got nil")
}
}
func TestValidateAll_multipleAccountsAllOK(t *testing.T) {
tmp := t.TempDir()
req := testRequired(tmp)
homes := []string{
filepath.Join(tmp, "a"),
filepath.Join(tmp, "b"),
}
if err := ValidateAll(homes, req); err != nil {
t.Fatalf("ValidateAll: %v", err)
}
}
func TestValidateAll_aggregatesErrors(t *testing.T) {
tmp := t.TempDir()
req := testRequired(tmp)
homes := []string{
filepath.Join(tmp, "a"),
filepath.Join(tmp, "b"),
}
// Pre-seed account `a` with a divergent link so ValidateAll must
// surface that error while still processing account `b`.
if err := os.MkdirAll(homes[0], 0700); err != nil {
t.Fatalf("mkdir a: %v", err)
}
wrongTarget := filepath.Join(tmp, "bad")
if err := os.MkdirAll(wrongTarget, 0700); err != nil {
t.Fatalf("mkdir bad: %v", err)
}
if err := os.Symlink(wrongTarget, filepath.Join(homes[0], "projects")); err != nil {
t.Fatalf("seed wrong link: %v", err)
}
err := ValidateAll(homes, req)
if err == nil {
t.Fatal("expected aggregated error, got nil")
}
if !strings.Contains(err.Error(), "divergent") {
t.Errorf("should surface divergent: %v", err)
}
// Account `b` must have been configured successfully even though `a`
// failed. Otherwise the operator cannot see the full state at once.
for _, sl := range req {
if _, err := os.Lstat(filepath.Join(homes[1], sl.Name)); err != nil {
t.Errorf("account b link %s should have been created despite a's failure: %v", sl.Name, err)
}
}
}
func TestValidateAll_emptyListReturnsError(t *testing.T) {
if err := ValidateAll(nil, nil); err == nil {
t.Fatal("expected error for empty account list")
}
}
// TestRequiredShared_defaultsAreReasonable pins the default SharedSymlink
// list so an accidental edit that breaks production is caught.
func TestRequiredShared_defaultsAreReasonable(t *testing.T) {
want := map[string]string{
"session-env": "/home/ubuntu/.claude-session-env-shared",
"file-history": "/home/ubuntu/.claude-file-history-shared",
"projects": "/home/ubuntu/.claude-projects-shared",
}
if len(RequiredShared) != len(want) {
t.Fatalf("RequiredShared has %d entries, want %d", len(RequiredShared), len(want))
}
for _, sl := range RequiredShared {
if got, ok := want[sl.Name]; !ok {
t.Errorf("unexpected RequiredShared entry %q", sl.Name)
} else if got != sl.Target {
t.Errorf("RequiredShared %q target = %q, want %q", sl.Name, sl.Target, got)
}
}
}