Skip to content
Merged
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
14 changes: 5 additions & 9 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ func v3NextSession(current session.Place, workspace string) (session.Place, erro
// cannot be inherited: it is minted with the folder, below.
root = workspace
}
return v3MintSession(filepath.Dir(dir), root,
return v3MintSession(session.BucketOf(current.Transcript()), root,
v3StampLaunchDir(v3LaunchDir(), root), current.Owned)
}
bucket, err := v3ProjectDir(workspace)
Expand Down Expand Up @@ -653,16 +653,12 @@ type v3Folder struct {
// deliverables is never empty whatever its journal says — those are somebody's
// content, and no groom of a bookkeeping file may take them.
func v3ScanBucket(bucket string) (spoken, empty []v3Folder) {
entries, err := os.ReadDir(bucket)
if err != nil {
sessionDirs := session.BucketSessionDirs(bucket)
if len(sessionDirs) == 0 {
return nil, nil
}
protected, known := v3TeamConversations()
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := cell.Copied(filepath.Join(bucket, entry.Name()))
for _, dir := range sessionDirs {
place := session.Place{Dir: dir}
if _, err := os.Stat(place.Transcript()); err != nil {
continue
Expand All @@ -671,7 +667,7 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) {
folder.meta, _ = session.LoadMeta(dir)
folder.at = folder.meta.LastUserAt
if folder.at.IsZero() {
if info, err := entry.Info(); err == nil {
if info, err := os.Stat(dir); err == nil {
folder.at = info.ModTime()
}
}
Expand Down
6 changes: 5 additions & 1 deletion cmd/codeaf/doctor_repair.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,13 @@ func scanLocalChatInventory() localChatInventory {
if strings.HasSuffix(base, ".replaced") {
continue
}
if filepath.Base(filepath.Dir(entry)) == ".moving" && fileExists(entry+".lock") && !fileExists(filepath.Join(entry, filepath.FromSlash(cell.CopyReadyPath))) {
continue
}
hasTranscript := fileExists(session.Place{Dir: entry}.Transcript())
hasCell := fileExists(filepath.Join(entry, filepath.FromSlash(cell.MetaPath)))
if hasTranscript || hasCell {
hasMeta := fileExists(filepath.Join(entry, "meta.json"))
if hasTranscript || hasCell || hasMeta {
id := base
if meta, err := session.LoadMeta(entry); err == nil && strings.TrimSpace(meta.ID) != "" {
id = strings.TrimSpace(meta.ID)
Expand Down
114 changes: 114 additions & 0 deletions cmd/codeaf/doctor_repair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -372,3 +374,115 @@ func TestDoctorFindsAndRepairsSyncAndEngineStoreSafely(t *testing.T) {
t.Fatalf("doctor --fix deleted .taking folder containing unique turns: %v", err)
}
}

// TestDoctorDoesNotCreateRecordsOnSyncService verifies that read-only
// `codeaf doctor` makes only a read-only listing call under the person's own
// identity and creates no namespaces, devices, cells, or Raft stores on the
// sync service (and makes zero requests when sync is off or unpaired).
func TestDoctorDoesNotCreateRecordsOnSyncService(t *testing.T) {
celltest.Available(t)
storeDir := t.TempDir()
svc := relayserve.New(relayserve.Config{Store: storeDir})
t.Cleanup(func() { _ = svc.Close() })

var mu sync.Mutex
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requests = append(requests, r.Method+" "+r.URL.Path)
mu.Unlock()
svc.Handler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)

homeA := t.TempDir()
t.Setenv(home.EnvVar, homeA)
t.Setenv(syncsetup.URLVar, srv.URL)

if _, err := identity.Ensure(homeA); err != nil {
t.Fatal(err)
}
syncA, ok, err := syncsetup.Open(homeA)
if err != nil || !ok {
t.Fatalf("syncsetup.Open(homeA) = (%v, %v)", ok, err)
}
if err := syncsetup.PutOwnDevice(context.Background(), syncA.Dir, syncA.Identity, syncA.Device, "desk"); err != nil {
t.Fatal(err)
}

readNames := func(dir string) []string {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatal(err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
slices.Sort(names)
return names
}

dirNamesBefore := readNames(filepath.Join(storeDir, "directory"))
raftNamesBefore := readNames(filepath.Join(storeDir, "raft"))

mu.Lock()
requests = nil
mu.Unlock()

dbPath := filepath.Join(t.TempDir(), "graph.db")
var out bytes.Buffer
if err := runDoctorWith([]string{"--db", dbPath}, &out, 0); err != nil {
t.Fatal(err)
}

mu.Lock()
gotRequests := slices.Clone(requests)
requests = nil
mu.Unlock()

if !slices.Equal(gotRequests, []string{"GET /v1/dir/list"}) {
t.Fatalf("doctor HTTP requests = %v, want only [GET /v1/dir/list]", gotRequests)
}
if got := readNames(filepath.Join(storeDir, "directory")); !slices.Equal(got, dirNamesBefore) {
t.Fatalf("relay directory files changed after doctor: got %v, want %v", got, dirNamesBefore)
}
if got := readNames(filepath.Join(storeDir, "raft")); !slices.Equal(got, raftNamesBefore) {
t.Fatalf("relay raft files changed after doctor: got %v, want %v", got, raftNamesBefore)
}

// Sync off: zero HTTP requests.
t.Setenv(syncsetup.URLVar, "off")
var offOut bytes.Buffer
if err := runDoctorWith([]string{"--db", dbPath}, &offOut, 0); err != nil {
t.Fatal(err)
}
mu.Lock()
offRequests := slices.Clone(requests)
requests = nil
mu.Unlock()
if len(offRequests) != 0 {
t.Fatalf("doctor sent %v with CODEAF_SYNC_URL=off, want none", offRequests)
}

// Unpaired solo home: zero HTTP requests.
soloHome := t.TempDir()
if _, err := identity.EnsureSolo(soloHome); err != nil {
t.Fatal(err)
}
t.Setenv(home.EnvVar, soloHome)
t.Setenv(syncsetup.URLVar, srv.URL)
var soloOut bytes.Buffer
if err := runDoctorWith([]string{"--db", dbPath}, &soloOut, 0); err != nil {
t.Fatal(err)
}
mu.Lock()
soloRequests := slices.Clone(requests)
mu.Unlock()
if len(soloRequests) != 0 {
t.Fatalf("doctor sent %v on unpaired solo home, want none", soloRequests)
}
}
8 changes: 7 additions & 1 deletion cmd/codeaf/olderchats.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ func olderChatCopyable(meta session.Meta) bool {

// movablePlace resolves a completed copy or prepares one when the person opens
// an older chat. Selection runs on the existing asynchronous opening door.
// Sync off means not talking to the service, never forgetting a completed copy
// already on this computer, so an existing `.moving` copy is opened even when
// sync is quiet or off.
func movablePlace(ctx context.Context, place session.Place) (session.Place, error) {
if place.Dir == "" || !cell.Available() || !syncsetup.MayTalk(home.Dir()) {
if place.Dir == "" {
return place, nil
}
if _, err := cell.OpenAt(place.Dir, place.ID()); err == nil {
Expand All @@ -174,6 +177,9 @@ func movablePlace(ctx context.Context, place session.Place) (session.Place, erro
original := place.Dir
copied := cell.Copied(original)
if copied == original {
if !cell.Available() || !syncsetup.MayTalk(home.Dir()) {
return place, nil
}
if _, err := os.Stat(filepath.Join(original, "transcript.jsonl")); os.IsNotExist(err) {
return place, nil
}
Expand Down
Loading
Loading