Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/Agent-Field/codeaf/internal/codexauth"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/delegate/builtin"
"github.com/Agent-Field/codeaf/internal/furrow"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
lanes "github.com/Agent-Field/codeaf/internal/lane"
Expand All @@ -45,6 +46,12 @@ import (

func main() {
home.Adopt(log.Printf)
// The furrow snapshot store follows the state root now ([runBinary] names
// FURROW_DATA_DIR for every child), and a store a pre-fix build left in the
// OS data root is carried into the default profile once so its timelines
// and forks are not orphaned (internal/furrow/adopt.go). Best effort: a
// failure here costs one logged line.
furrow.Adopt(log.Printf)
// CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that
// automations replaced installed are taken off this login on every start,
// whoever installed them (internal/automation's legacy.go).
Expand Down Expand Up @@ -687,8 +694,9 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row.
1 spends past the day's limit without stopping a
headless run to ask
CODEAF_HOME the whole state root — journal, workspace, CAS, craft,
profiles, catalog, skills (default ~/.codeaf). Move it
to run a disposable store that touches nothing of yours.
profiles, catalog, skills and furrow's snapshot store
(default ~/.codeaf). Move it to run a disposable store
that touches nothing of yours.
CODEAF_NO_UPDATE_CHECK
1 skips the launch check and the update a released
codeaf installs as it opens; /update and codeaf
Expand Down
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1769-furrow-store-under-codeaf-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: The furrow snapshot store follows CODEAF_HOME, not the login's data root
pr: 1769
surface: [chat, engine]
invalidates:
- "furrow's snapshot store lived in the login's OS data root (`~/Library/Application Support/dev.furrow.furrow` on macOS), so two profiles that shared a HOME shared one store. It now lives under the state root (`CODEAF_HOME/furrow`, default `~/.codeaf/furrow`), a store found at the old root is moved there once on the next start, and a link left at the old root keeps an older binary resolving the same store."
- "`CODEAF_HOME` was described as the whole state root but left furrow's store out; the `help env` row now names it."
---

On macOS and Linux, `codeaf chat` sessions launched with their own CODEAF_HOME
still wrote furrow's snapshot store under the shared HOME (and test runs on a
developer's machine wrote into the developer's own store). Every furrow child
now receives a `FURROW_DATA_DIR` under the state root, and the one previous
store a profile may already own is carried into its new home once, with a
link at the old root so an older binary reads the same store.
57 changes: 57 additions & 0 deletions internal/e2e/furrowhome_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//go:build e2e

package e2e

import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)

// The acceptance of #1769: a session launched with its own CODEAF_HOME, behind
// the host guard's throwaway HOME, must keep furrow's snapshot store inside
// the state root and leave the platform data root empty. Before the fix the
// store went wherever furrow's own default put it — the OS data root of the
// login, `~/Library/Application Support/dev.furrow.furrow` on macOS — so two
// profiles that shared a HOME shared one store, and test drives wrote into the
// developer's own store.
//
// One turn is the unit: the belt is assembled per turn, and the belt's furrow
// detection is the first status call, which opens (and therefore creates) the
// store eagerly even in a folder furrow was never attached to.
func TestTheFurrowStoreFollowsTheStateRoot(t *testing.T) {
requireTmuxAndKey(t)
home := newHome(t, nil)
ws := newWorkspace(t, "furrow-home", false)
r := start(t, "furrow_home", home, ws, 100, 32)

r.lit("Answer with only these words joined by spaces: Furrow / store / check.")
r.keys("Enter")
r.waitFor(3*time.Minute, "Furrow store check")

store := filepath.Join(home, "furrow", "store-v1")
if _, err := os.Stat(store); err != nil {
t.Fatalf("the snapshot store did not land under the state root: %v (CODEAF_HOME=%s)", err, home)
}
// The login the guard gave this launch is throwaway; furrow's default data
// root there must not exist, or the store still escaped the profile.
legacyRoot := furrowDefaultDataRoot(r.host.login)
if _, err := os.Stat(legacyRoot); !os.IsNotExist(err) {
t.Fatalf("the login's data root holds a furrow store: %s", legacyRoot)
}
t.Logf("store under the state root: %s\ndefault data root empty: %s", store, legacyRoot)
}

// furrowDefaultDataRoot is where furrow's own default would put its store on
// this platform: the directories crate's ProjectDirs::from("dev", "furrow",
// "furrow").data_dir(), verified against the pinned v0.1.0 source. The
// qualifier shapes only macOS; Linux uses the lowercase application name under
// the XDG data root (which the guard does not set, so the fallback applies).
func furrowDefaultDataRoot(login string) string {
if runtime.GOOS == "darwin" {
return filepath.Join(login, "Library", "Application Support", "dev.furrow.furrow")
}
return filepath.Join(login, ".local", "share", "furrow")
}
140 changes: 140 additions & 0 deletions internal/furrow/adopt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package furrow

import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/home"
)

// Logf is the one-line reporting door boot adoption uses when best effort was
// not enough. It matches log.Printf without making this package choose a log.
type Logf func(string, ...any)

// Adopt moves a furrow snapshot store that predates the state-root rule into
// the default profile once. Before codeaf started naming FURROW_DATA_DIR
// ([runBinary]), a store lived wherever furrow's own default put it — the OS
// data root of the login, `~/Library/Application Support/dev.furrow.furrow` on
// macOS, `~/.local/share/furrow` on Linux. Pointing every child at the state
// root's own store orphans months of sealed timelines and forks unless they
// are carried across, so the first start of the new layout moves them. A link
// left at the old root keeps an older binary — a resident of an earlier build
// still running, a downgraded copy — resolving the same store: the companion
// link home.Adopt leaves at the old state folder, here for the store.
//
// It is a boot operation, not store resolution: the real binary calls it from
// main, and the testing guard makes an accidental library call harmless in
// test binaries. It is default-profile only — a CODEAF_HOME the caller named
// is a deliberate separation, and the old store belongs to the profile it
// leaves behind, not to the disposable one. Best effort throughout: a store
// furrow has already opened at the new place wins, and every failure is
// reported on one line and forgotten, the way [home.Adopt] treats its own.
func Adopt(logf Logf) {
if testing.Testing() {
return
}
adoptProfile(logf)
}

func adoptProfile(logf Logf) {
if _, set := env.Lookup(home.EnvVar); set {
return
}
base, err := os.UserHomeDir()
if err != nil {
adoptionFailed(logf, err)
return
}
adopt(base, logf, adoptOps{
lstat: os.Lstat,
mkdirAll: os.MkdirAll,
rename: os.Rename,
symlink: os.Symlink,
})
}

type adoptOps struct {
lstat func(string) (os.FileInfo, error)
mkdirAll func(string, os.FileMode) error
rename func(string, string) error
symlink func(string, string) error
}

func adopt(base string, logf Logf, operations adoptOps) {
current := home.Join("furrow", "store-v1")
legacy := filepath.Join(platformDataRoot(base), "store-v1")
if _, err := operations.lstat(current); err == nil {
// A store already lives under the state root — this profile has been
// used since the rule, or another process is mid-flight. Leave both.
return
} else if !os.IsNotExist(err) {
adoptionFailed(logf, err)
return
}
info, err := operations.lstat(legacy)
if os.IsNotExist(err) {
return
}
if err != nil {
adoptionFailed(logf, err)
return
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return
}
if err := operations.mkdirAll(home.Join("furrow"), 0o700); err != nil {
adoptionFailed(logf, err)
return
}
if err := operations.rename(legacy, current); err != nil {
adoptionFailed(logf, err)
return
}
if logf != nil {
logf("codeaf: moved furrow's snapshot store from %s to %s", legacy, current)
}
if operations.symlink != nil {
// THE OLD BUILD KEEPS READING THE SAME STORE. A process of an older
// binary still running — a resident that outlived an update — resolves
// the OS data root and would otherwise open a fresh empty store there,
// splitting the timelines between two stores nobody sees both of.
// Redirect the old path to the new one; relative, like home.Adopt's,
// so the pair survives the home directory moving. Best effort: the move
// above is the adoption, this is its compatibility seam.
target, err := filepath.Rel(filepath.Dir(legacy), current)
if err != nil {
target = current
}
if err := operations.symlink(target, legacy); err != nil {
adoptionFailed(logf, err)
}
}
}

// platformDataRoot is where furrow's own default puts its data on this
// platform: the directories crate's ProjectDirs::from("dev", "furrow",
// "furrow").data_dir(), verified against the pinned v0.1.0 source's
// data_root(). The qualifier only shapes macOS, and the login's OS goes here
// because the legacy store was chosen by the OS the store ran on.
func platformDataRoot(base string) string {
switch runtime.GOOS {
case "darwin":
return filepath.Join(base, "Library", "Application Support", "dev.furrow.furrow")
default:
// Linux: ~/.local/share/furrow, or $XDG_DATA_HOME when the person
// moved the XDG data root (directories reads it before falling back).
if xdg := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(xdg) {
return filepath.Join(xdg, "furrow")
}
return filepath.Join(base, ".local", "share", "furrow")
}
}

func adoptionFailed(logf Logf, err error) {
if logf != nil {
logf("codeaf: could not adopt furrow's former snapshot store: %v", err)
}
}
112 changes: 112 additions & 0 deletions internal/furrow/adopt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package furrow

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/Agent-Field/codeaf/internal/home"
)

// A store that predates the state-root rule is carried into the default
// profile once: the timeline and forks a person sealed while the store sat in
// the OS data root must not be orphaned by the move. The rename is recorded,
// not assumed — this test pins WHICH directories move where, because the whole
// fix is that the store's home changes.
func TestAdoptMovesALegacyStoreIntoTheDefaultProfile(t *testing.T) {
base := t.TempDir()
legacy := filepath.Join(platformDataRoot(base), "store-v1")
if err := os.MkdirAll(legacy, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacy, "timeline.db"), []byte("sealed work"), 0o600); err != nil {
t.Fatal(err)
}
current := home.Join("furrow", "store-v1")
var moved [][2]string
var links [][2]string
var lines []string
adopt(base, func(format string, args ...any) { lines = append(lines, format) }, adoptOps{
lstat: os.Lstat,
mkdirAll: os.MkdirAll,
rename: func(from, to string) error {
moved = append(moved, [2]string{from, to})
return os.Rename(from, to)
},
symlink: func(target, name string) error {
links = append(links, [2]string{target, name})
return os.Symlink(target, name)
},
})
if len(moved) != 1 || moved[0] != [2]string{legacy, current} {
t.Fatalf("renames = %v; want exactly %s -> %s", moved, legacy, current)
}
if _, err := os.Stat(filepath.Join(current, "timeline.db")); err != nil {
t.Fatalf("the store did not land under the state root: %v", err)
}
// AN OLDER BINARY MUST STILL RESOLVE THE OLD ROOT TO THE SAME STORE: the
// move is the adoption, and the link left at the old root is its
// compatibility seam. A pre-fix resident still running would otherwise
// open a fresh empty store where the old one sat, and the timelines would
// be split between two stores nobody sees both of.
target, err := os.Readlink(legacy)
if err != nil {
t.Fatalf("no link left at the old root: %v", err)
}
if resolved := filepath.Clean(filepath.Join(filepath.Dir(legacy), target)); resolved != current {
t.Fatalf("the link at %s resolves to %s; want the moved store %s", legacy, resolved, current)
}
if len(links) != 1 || links[0][1] != legacy {
t.Fatalf("links = %v; want exactly one link at %s", links, legacy)
}
if len(lines) != 1 || !strings.Contains(lines[0], "moved furrow's snapshot store") {
t.Fatalf("log lines = %v; want one line saying the store moved", lines)
}
// A second pass finds the current store and leaves everything alone — the
// renamed store and the link both stay exactly as they are.
moved, links = nil, nil
adopt(base, nil, adoptOps{lstat: os.Lstat, mkdirAll: os.MkdirAll, rename: func(from, to string) error {
moved = append(moved, [2]string{from, to})
return nil
}, symlink: func(target, name string) error {
links = append(links, [2]string{target, name})
return nil
}})
if len(moved) != 0 || len(links) != 0 {
t.Fatalf("second pass renamed %v and linked %v; a live store must win", moved, links)
}
if _, err := os.Lstat(legacy); err != nil {
t.Fatalf("the link at the old root was disturbed: %v", err)
}
}

// With nothing at the OS data root there is nothing to adopt, and a legacy
// store that is a symlink is left alone — the same caution home.Adopt applies
// to its own legacy folder.
func TestAdoptLeavesNothingAndNoSymlinksAlone(t *testing.T) {
base := t.TempDir()
legacy := filepath.Join(platformDataRoot(base), "store-v1")
touched := false
noOps := adoptOps{
lstat: os.Lstat,
mkdirAll: os.MkdirAll,
rename: func(from, to string) error { touched = true; return nil },
symlink: func(target, name string) error { touched = true; return nil },
}
adopt(base, nil, noOps)
if touched {
t.Fatal("adopted a store that was not there")
}
if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(home.Join("furrow", "store-v1"), legacy); err != nil {
t.Fatal(err)
}
touched = false
adopt(base, nil, noOps)
if touched {
t.Fatal("a symlink was adopted as a store")
}
}
Loading