diff --git a/VERSION.md b/VERSION.md index fe1d813..c699e3c 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1,4 +1,30 @@ -# Version actuelle : 0.3.4 +# Version actuelle : 0.3.5 + +## [0.3.5] - 2026-04-16 +**Type:** Patch — Phase 1 / Chantier A1 : package `internal/symlinks` + +### Ajouté +- `internal/symlinks/shared.go` : `EnsureForAccount` + `ValidateAll` qui + encodent en code la convention des 3 symlinks partagés par compte + (`session-env`, `file-history`, `projects`). Jusqu'à aujourd'hui ces + liens étaient maintenus à la main et leur absence silencieuse cassait + le failover (JSONL dupliqués, undo désynchronisé). +- Tests unitaires couvrant : création missing, idempotence, divergence + (refus d'auto-correction pour éviter la perte de données), fichier + régulier à la place du lien, home vide, agrégation d'erreurs multi-comptes. + +### Rationale +- Un déploiement sur une nouvelle VM ne peut plus omettre les liens. +- Divergent link → erreur explicite, jamais de correction silencieuse. +- Préparation des tâches A2 (ValidateAll au startup) et A3 (EnsureForAccount + post-flipSymlink dans le switcher). + +### Tests +- ✅ `go test ./internal/symlinks/...` : 9/9 PASS + +### Fichiers ajoutés +- `internal/symlinks/shared.go` +- `internal/symlinks/shared_test.go` ## [0.3.4] - 2026-04-16 **Type:** Patch — Dispatcher ne route JAMAIS vers les sessions dédiées diff --git a/WORK_IN_PROGRESS.md b/WORK_IN_PROGRESS.md index 049b34a..7538918 100644 --- a/WORK_IN_PROGRESS.md +++ b/WORK_IN_PROGRESS.md @@ -1,13 +1,26 @@ # Travaux en Cours - claude-failover ## Dernière mise à jour -2026-04-15 19:30:00 +2026-04-16 19:00:00 ## Version Actuelle -0.3.0 +0.3.5 (en cours de progression vers 0.4.0) ## Demande Actuelle -Aucune — v0.2.3 shippée, service stable. +**Phase 1 / Chantier A — Failover robuste** (spec dans `ccl-platform/phases/phase1/A-failover.md`). +Rendre le failover compte1 ↔ compte2 déterministe en intégrant dans le code les fixes manuels +(symlinks partagés), en ajoutant un registre UUID fiable, et en durcissant tmux send-keys. + +Branche : `feat/phase1-A-failover-robust`. + +## Sous-tâches Chantier A +- [x] A1 — `internal/symlinks/shared.go` (+ tests) — v0.3.5 +- [ ] A2 — `lifecycle/manager.go` : `ValidateAll` au startup +- [ ] A3 — `switcher/account_switcher.go` : `EnsureForAccount` post-flip +- [ ] A4 — `internal/registry/uuid_registry.go` (+ tests) +- [ ] A5 — `internal/tmux/send.go` avec retry exponentiel (+ tests) +- [ ] A6 — Capture UUID 200 → 500 lignes +- [ ] A7 — `scripts/test-failover.sh` dans ccl-platform + scripts associés ## Étapes Complétées - [x] v0.2.1 — Cooldown post-swap + log forensique (trigger_session, pattern, snippet) diff --git a/internal/symlinks/shared.go b/internal/symlinks/shared.go new file mode 100644 index 0000000..7687fb0 --- /dev/null +++ b/internal/symlinks/shared.go @@ -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 +} diff --git a/internal/symlinks/shared_test.go b/internal/symlinks/shared_test.go new file mode 100644 index 0000000..64625d4 --- /dev/null +++ b/internal/symlinks/shared_test.go @@ -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) + } + } +}