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
16 changes: 16 additions & 0 deletions docs/changes/unreleased/2540-pair-fresh-and-ignored-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: A computer that only opened codeaf can pair, and the local relay stops rebuilding in a loop
pr: 2540
surface: [chat, engine, docs]
invalidates:
- "Opening codeaf once and quitting left an empty conversation that made codeaf pair refuse with 'this computer has local work'; a computer with nothing but that empty conversation now pairs by code or link without --replace, while a sent turn, saved memories, settings or any staged or committed file still refuse."
- "npm run dev for the hosted relay could rebuild and reload itself in a loop, because its build hook rewrote src/raft/core.wasm inside the watched folder, and its Node process grew to about 9 GB before aborting; an unchanged build now leaves core.wasm untouched and the local wrapper bounds Node's heap."
---
A computer that has only been started holds nothing to lose, so it joins
without being told to replace. Files staged or committed in its own repository,
including ones whose names are only spaces, still count as work.

The local relay's build hook builds the reactor beside the tree and moves it
into place only when its bytes differ, and `npm run dev` caps Node's heap at
2 GB unless the caller sets its own limit.
78 changes: 78 additions & 0 deletions internal/identity/identitytest/started.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Package identitytest builds a closed, unused chat through its storage owners.
package identitytest

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

"github.com/Agent-Field/codeaf/internal/cell"
"github.com/Agent-Field/codeaf/internal/cellbudget"
"github.com/Agent-Field/codeaf/internal/cellstore"
"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/inventory"
"github.com/Agent-Field/codeaf/internal/session"
)

// OpenAndQuit exercises session creation without sending a request to a model.
// Its extra surface files were observed after skipping setup and typing /quit.
func OpenAndQuit(t *testing.T, home string, owned bool) session.Place {
t.Helper()
t.Setenv("HOME", home)
t.Setenv("CODEAF_HOME", home)
t.Setenv("CODEAF_TELEMETRY", "off")
t.Setenv("DO_NOT_TRACK", "1")
t.Setenv("CODEAF_NO_UPDATE_CHECK", "1")
t.Setenv("CODEAF_NO_AUTOMATIONS", "1")
if _, err := identity.EnsureSolo(home); err != nil {
t.Fatal(err)
}
c, err := cell.CreateIn(filepath.Join(home, "v3/projects/project"), cell.Options{Class: cell.HostBound})
if err != nil {
t.Fatal(err)
}
place := session.Place{Dir: c.Root, Workspace: t.TempDir(), Owned: owned}
if owned {
place.Workspace = filepath.Join(c.Root, "work")
if err := os.MkdirAll(place.Workspace, 0o700); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=codeaf", "-c", "user.email=test@localhost", "commit", "--allow-empty", "--no-verify", "-qm", "session opened"}} {
cmd := exec.Command("git", args...) //codeaf:plumbing An owned chat starts with an empty repository.
cmd.Dir = place.Workspace
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("prepare empty workspace: %s: %v", out, err)
}
}
}
if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Workspace: place.Workspace, Owned: owned}); err != nil {
t.Fatal(err)
}
a, err := session.New(session.Config{Workspace: place.Workspace, Model: "stub/scripted", BaseURL: "http://127.0.0.1:9/api/v1", APIKey: "stub", Place: place, SessionFile: place.Transcript()})
if err != nil {
t.Fatal(err)
}
if err := a.Close(); err != nil {
t.Fatal(err)
}
if err := inventory.Record(c.Root, func(inv *inventory.Inventory) { inv.Workspace = place.Workspace }); err != nil {
t.Fatal(err)
}
if err := (cellstore.Guard{Home: home}).Screen(c, place.Workspace, filepath.Join(c.Root, cell.StateDir), nil); err != nil {
t.Fatal(err)
}
manager := cellbudget.New(cellstore.Engine{DataRoot: filepath.Join(home, "v3/stores")}, func(cell.Cell) string { return place.Workspace }, nil)
if err := manager.Open(t.Context(), c); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"v3/draft-started.json": `{"version":1,"owner":"chat"}`,
"v3/history.jsonl": "{\"text\":\"/quit\"}\n",
} {
if err := os.WriteFile(filepath.Join(home, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return place
}
48 changes: 48 additions & 0 deletions internal/identity/opened_chat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package identity_test

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

"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/identity/identitytest"
)

func TestAComputerThatOnlyOpenedAndQuitAChatCanJoin(t *testing.T) {
for _, owned := range []bool{false, true} {
t.Run(map[bool]string{false: "borrowed project", true: "owned project"}[owned], func(t *testing.T) {
home := t.TempDir()
place := identitytest.OpenAndQuit(t, home, owned)
if ok, err := identity.Joinable(home); !ok || err != nil {
t.Fatalf("an opened and quit chat cannot join: %v, %v", ok, err)
}
for _, name := range []string{"memories.jsonl", "state.json", "env/notes.txt"} {
path := filepath.Join(place.Dir, ".cell", name)
if err := os.WriteFile(path, []byte("keep my work"), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := identity.Joinable(home); ok || err != nil {
t.Fatalf("%s must keep the home from joining: %v, %v", name, ok, err)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
}
path := filepath.Join(place.Dir, ".cell", "env/inventory.json")
for _, body := range []string{
`{"V":1,"platform":{},"annotations":{"note":{"text":"keep"}}}`,
`{"V":1,"platform":{"notes":"keep"}}`,
`{"V":2,"platform":{}}`,
`{"V":1,"platform":{}} {"notes":"keep"}`,
} {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if ok, err := identity.Joinable(home); ok || err != nil {
t.Fatalf("inventory notes or unknown contents must remain work: %v, %v: %s", ok, err, body)
}
}
})
}
}
51 changes: 51 additions & 0 deletions internal/identity/opened_repository_work_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package identity_test

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

"github.com/Agent-Field/codeaf/internal/identity"
"github.com/Agent-Field/codeaf/internal/identity/identitytest"
)

// Staged or committed work in an owned chat's own repository keeps the home
// from joining, even when the file has gone from the folder and its name is
// nothing but spaces, which a trimmed listing used to read as no name at all.
func TestStagedOrCommittedWorkInAnOwnedRepositoryKeepsAHomeFromJoining(t *testing.T) {
for _, name := range []string{" "} {
for _, step := range []string{"staged", "committed"} {
t.Run(step+" "+map[bool]string{true: "a blank name", false: name}[name == " "], func(t *testing.T) {
home := t.TempDir()
place := identitytest.OpenAndQuit(t, home, true)
if ok, err := identity.Joinable(home); !ok || err != nil {
t.Fatalf("the untouched opened home must join first: %v, %v", ok, err)
}
work := place.Workspace
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"}, args...)...)
cmd.Dir = work
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(filepath.Join(work, name), []byte("keep my work"), 0o600); err != nil {
t.Fatal(err)
}
git("add", "--", name)
if step == "committed" {
git("commit", "-q", "-m", "work")
}
// The file leaves the folder, so only Git still knows about it.
if err := os.Rename(filepath.Join(work, name), filepath.Join(t.TempDir(), "moved")); err != nil {
t.Fatal(err)
}
if ok, err := identity.Joinable(home); ok || err != nil {
t.Fatalf("%s work named %q must keep the home from joining: %v, %v", step, name, ok, err)
}
})
}
}
}
7 changes: 6 additions & 1 deletion internal/identity/pristine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"io/fs"
"os"

"github.com/Agent-Field/codeaf/internal/inventory/layout"
)

// Pristine says whether the home holds nothing but what starting codeaf makes:
Expand Down Expand Up @@ -72,8 +74,11 @@ var started = within(startupRules)
// its transcript and memories are the chat itself.
var withinSession = within(rules{
"meta.json": never, "meta.lock": never, "presence.json": never,
// An owned chat starts with an empty repository before any request is sent.
"work": within(rules{".git": unopenedRepository}),
".cell": within(rules{
"meta.json": never, "session.json": never, ".furrowpolicy": never, "env": empty,
"meta.json": never, "session.json": never, ".furrowpolicy": never,
"env": within(rules{layout.File: startupInventory}),
"memories.jsonl": empty, "transcript.jsonl": noTurns,
}),
})
45 changes: 45 additions & 0 deletions internal/identity/pristine_judges.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package identity
import (
"bufio"
"bytes"
"context"
"database/sql"
"encoding/json"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/sqliteuri"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

_ "modernc.org/sqlite" // reads the memory graph, which is a SQLite file
)
Expand Down Expand Up @@ -72,6 +75,48 @@ func pickerCache(path string) (bool, error) {
// unknown is the judge of a name nobody declared, and it is always use.
func unknown(string) (bool, error) { return true, nil }

// startupInventory admits the platform and locations recorded before any call.
// Observed tools, notes and all other fields still count as work.
func startupInventory(path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return true, err
}
var inv struct {
V uint16
Platform struct{ OS, Arch string }
Workspace string
ChatDir string `json:"chat_dir"`
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if decoder.Decode(&inv) != nil || inv.V != 1 {
return true, nil
}
return decoder.Decode(new(json.RawMessage)) != io.EOF, nil
}

// unopenedRepository keeps even deleted or staged project work from being
// mistaken for the empty repository made when an owned chat first opens.
//
// ANY NAME GIT PRINTS IS WORK. The names are read NUL-terminated and never
// trimmed: a file named only with spaces, staged and then deleted, printed a
// blank line that trimming discarded, and the repository passed as untouched.
// For the startup repository both listings print nothing at all.
func unopenedRepository(path string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, args := range [][]string{{"log", "--all", "--format=", "--name-only", "-z"}, {"ls-files", "--cached", "-z"}} {
cmd := exec.CommandContext(ctx, "git", append([]string{"--no-optional-locks"}, args...)...) //codeaf:plumbing An empty owned repository contains no saved or staged files.
cmd.Dir = filepath.Dir(path)
raw, err := cmd.Output()
if err != nil || len(raw) > 0 {
return true, err
}
}
return false, nil
}

// empty is for what holds the person's words and bytes when it holds any: a
// file with no bytes, or a folder of such files.
func empty(path string) (bool, error) {
Expand Down
3 changes: 2 additions & 1 deletion internal/inventory/inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ package inventory

import (
"github.com/Agent-Field/codeaf/internal/cell"
"github.com/Agent-Field/codeaf/internal/inventory/layout"
)

// Path is the cell-relative location of the inventory file.
const Path = cell.EnvPath + "/inventory.json"
const Path = cell.EnvPath + "/" + layout.File

const schemaV = 1

Expand Down
6 changes: 6 additions & 0 deletions internal/inventory/layout/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Package layout names inventory storage without depending on its live observers.
package layout

// File is shared by inventory writers and the unused-home reader, which cannot
// import the live inventory package because its key storage depends on identity.
const File = "inventory.json"
6 changes: 6 additions & 0 deletions internal/manual/chat/add-or-remove-a-computer.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ With a self-hosted address (`CODEAF_SYNC_URL=<url>` or `--via <address>`), the l

A link is good for 10 minutes and one approval. If it ran out, run `codeaf pair` on the new device again. Ctrl+c while it waits ends with `Cancelled. Nothing was paired.`

## I only opened codeaf and quit — can this computer pair without --replace

Yes. Skipping setup and quitting an empty chat counts as starting codeaf, with either a pairing code or a link. Its empty transcript, draft, command history, inventory and empty working folder do not require `--replace`.

Once you send a message, that chat belongs to this computer. With one such chat, pairing says `this computer already has 1 chat of its own, so nothing was changed.` The rest of the message explains `--replace`: local chats stay here but stop syncing, and shared memories and settings are cleared. Saved knowledge, automations, project work, chat memories and unknown local files keep their existing protection.

## The four-digit check — how I know the right computer is asking

Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first.
Expand Down
Loading
Loading