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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/codeaf-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// codeaf-release next --channel dev|staging --sha <commit> --date YYYYMMDD
// codeaf-release kind <tag>
// codeaf-release prune --channel dev|staging
// codeaf-release service [--url <sync service>]
package main

import (
Expand Down Expand Up @@ -46,6 +47,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
case "prune":
err = runPrune(args[1:], stdin, stdout)
case "service":
err = runService(args[1:], stdout)
case "promotion-plan":
err = runPromotionPlan(args[1:], stdout)
case "promotion-message":
Expand Down Expand Up @@ -90,6 +93,9 @@ func usage(w io.Writer) {
Print stable, rc, dev, staging, or other.
prune --channel dev|staging
Read <tag><tab><RFC3339 createdAt> lines and print expired tags.
service [--url <sync service>]
Fail unless the live sync service (the hosted one by default) promises
every capability this build needs.
promotion-plan|promotion-decision|promotion-recheck|promotion-release|promotion-published|promotion-jobs|promotion-push-error|promotion-message
Plan, check, and report the staging promotion. See docs/rules/promotion.md.
`)
Expand Down
50 changes: 50 additions & 0 deletions cmd/codeaf-release/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"context"
"fmt"
"io"
"strings"
"time"

"github.com/Agent-Field/codeaf/internal/directory"
"github.com/Agent-Field/codeaf/internal/syncsetup"
)

// serviceProbeWithin bounds the live check, so a silent service fails the
// release instead of holding the workflow.
const serviceProbeWithin = 60 * time.Second

// probeService is the live check; tests replace it.
var probeService = syncsetup.ProbeService

// runService fails when the sync service a build will talk to does not promise
// what the build needs. A client never ships ahead of its service (#2072).
func runService(args []string, stdout io.Writer) error {
url := syncsetup.HostedRelayURL
switch {
case len(args) == 2 && args[0] == "--url":
url = args[1]
case len(args) != 0:
return usageErr("service takes only --url <sync service>")
}
ctx, cancel := context.WithTimeout(context.Background(), serviceProbeWithin)
defer cancel()
missing, err := probeService(ctx, url)
if err != nil {
return fmt.Errorf("could not check the sync service at %s: %w", url, err)
}
if len(missing) > 0 {
return fmt.Errorf("the sync service at %s must be updated before this build ships; it does not promise %s", url, describe(missing))
}
fmt.Fprintf(stdout, "the sync service at %s promises everything this build needs\n", url)
return nil
}

func describe(missing []directory.Capability) string {
parts := make([]string, len(missing))
for i, c := range missing {
parts[i] = fmt.Sprintf("%s (needed for %s)", c.Header, c.Feature)
}
return strings.Join(parts, ", ")
}
30 changes: 30 additions & 0 deletions cmd/codeaf-release/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"bytes"
"context"
"strings"
"testing"

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

// TestServiceGateFailsAheadOfTheService pins #2072's release gate: a build
// whose service lacks a promise does not ship, and the refusal names it.
func TestServiceGateFailsAheadOfTheService(t *testing.T) {
defer func(was func(context.Context, string) ([]directory.Capability, error)) { probeService = was }(probeService)
probeService = func(context.Context, string) ([]directory.Capability, error) {
return directory.Capabilities, nil
}
var out, errs bytes.Buffer
if code := run([]string{"service", "--url", "https://sync.example"}, nil, &out, &errs); code != 1 {
t.Fatalf("exit %d, stderr %q", code, errs.String())
}
if !strings.Contains(errs.String(), directory.ConditionalTakeHeader) {
t.Fatalf("the refusal does not name the promise: %q", errs.String())
}
probeService = func(context.Context, string) ([]directory.Capability, error) { return nil, nil }
if code := run([]string{"service"}, nil, &out, &errs); code != 0 {
t.Fatalf("a complete service failed the gate: %q", errs.String())
}
}
35 changes: 27 additions & 8 deletions cmd/codeaf/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,24 +140,24 @@ func listDevices(kinds []deviceKind, now time.Time) error {
// `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")
all := flags.Bool("all", false, "remove every device of that name, computers that sync your chats included, 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 <name> [--all] — `codeaf devices` lists the names")
}
name := flags.Arg(0)
// A partial --all still says what it did stop before what it could not.
said, err := stopByName(kinds, name, *all)
if err != nil {
return err
if said != "" {
fmt.Println(said)
}
fmt.Println(said)
return nil
return err
}

// 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.
// --all it stops every device of that name, in every kind that has one.
func stopByName(kinds []deviceKind, name string, all bool) (string, error) {
if all {
return stopAllByName(kinds, name)
Expand All @@ -169,13 +169,32 @@ func stopByName(kinds []deviceKind, name string, all bool) (string, error) {
return kind.stop(name)
}

// stopAllByName stops every device called name. A kind that can stop several
// stops them all; any other kind stops its one, so --all never misses a device
// that `codeaf devices` lists (#2072).
func stopAllByName(kinds []deviceKind, name string) (string, error) {
var said []string
var failed error
for _, kind := range kinds {
n, err := kind.find(name)
failed = cmp.Or(failed, err)
if n == 0 {
continue
}
stop := kind.stop
if many, ok := kind.(allStopper); ok {
return many.stopAll(name)
stop = many.stopAll
}
line, err := stop(name)
if err != nil {
return strings.Join(said, "\n"), err
}
said = append(said, line)
}
if len(said) == 0 {
return "", cmp.Or(failed, unknownDevice(name))
}
return "", unknownDevice(name)
return strings.Join(said, "\n"), nil
}

// kindOf is the one kind that has a device called name. A name in two kinds is
Expand Down
29 changes: 26 additions & 3 deletions cmd/codeaf/devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,35 @@ func TestDevicesRevokeNamesThatCannotPickOne(t *testing.T) {
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.
// --all means every device of that name, whichever kind it is (#2072).
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")
if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked || l.Devices[thisComputer].Revoked {
t.Fatalf("--all did not stop the computer of that name: %+v", l.Devices)
}
if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 0 {
t.Fatalf("--all left the remote device of that name: %v", left)
}
}

// TestDevicesRevokeAllFindsAComputerThatSyncsChats pins #2072: a computer that
// `codeaf devices` lists is removed by `revoke <name> --all` as by
// `revoke <name>`, though no device of that name can use this machine.
func TestDevicesRevokeAllFindsAComputerThatSyncsChats(t *testing.T) {
chats, dir := chatsWith(t, "laptop")
kinds := []deviceKind{chats, bookKind{bookWith(t, "phone")}}
if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"laptop", "--all"}) }); err != nil {
t.Fatalf("revoke laptop --all: %v", err)
}
if l, _ := dir.List(devicesCtx); !l.Devices[otherComputer].Revoked {
t.Fatal("--all missed the computer that syncs the chats")
}
if left, _ := kinds[1].(bookKind).book.Devices(); len(left) != 1 {
t.Fatalf("--all stopped a device of another name: %v", left)
}
if _, err := captureStdout(t, func() error { return stopDevice(kinds, []string{"ghost", "--all"}) }); err == nil || !strings.Contains(err.Error(), "no device is called") {
t.Fatalf("revoke ghost --all: %v", err)
}
}

Expand Down
4 changes: 3 additions & 1 deletion cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,9 +429,11 @@ func nothingHolds(workspace string) bool {

// differentEngineEnvironmentSentence names the host's workspace so the command
// stops the folder actually holding the old environment, including from Home.
// It offers --no-host first: that opens this window with this terminal's
// environment and never interrupts the other window (#2072).
func differentEngineEnvironmentSentence(workspace string) string {
stop := engineStopWords(workspace)
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
return "the background engine for this folder started with a different environment than this terminal and is still in use — codeaf chat --no-host opens this window on its own and leaves the other one running; or, 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
Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/engine_environment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ func TestEnvironmentStopCommandQuotesTheWorkspaceForTheShell(t *testing.T) {
t.Run(workspace, func(t *testing.T) {
note := differentEngineEnvironmentSentence(workspace)
_, command, ok := strings.Cut(note, "), run ")
if !ok || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") {
if !ok || !strings.Contains(note, "codeaf chat --no-host opens this window on its own") || !strings.Contains(note, "after work finishes, close every window for this workspace and reopen") || !strings.Contains(note, "To stop now (interrupts active turns and tasks)") {
t.Fatalf("busy note does not explain the recovery: %s", note)
}
// Keep the production command's complete shell argument text. Only
Expand Down
25 changes: 25 additions & 0 deletions docs/changes/unreleased/2075-never-locked-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
kind: fixed
title: A chat can always be written on the computer in front of you, even on an older sync service
pr: 2075
surface: [chat, remote, build, docs]
invalidates:
- "A chat whose hold had lapsed could only be written after a conditional take, so on a sync service without codeaf-conditional-takes it opened read-only and Enter showed Continue this chat here? again; it is now taken and written on every service, and only taking a chat that is open on another computer waits for the update."
- "Turns this computer saved on top of the chat's last uploaded turn made a reopened chat read-only; they are now published when the chat opens."
- "A chat this computer held last and let lapse read as off and asked Continue this chat here? with this chat was last held here; it now reads as free and opens without the question."
- "Home offered Continue <device>'s latest chat here? for a device removed with codeaf devices revoke; a removed device is no longer offered."
- "codeaf devices revoke <name> --all looked only at devices that can use this machine and missed computers that sync chats; it now removes every device of that name of either kind."
- "The older-service refusal said the sync service must be updated before chats can move; it now says that taking an open chat from another computer waits for a sync service update and that everything else works."
---

A chat whose hold had lapsed needed a conditional take, which the client made
only when the service promised one. The deployed hosted service does not, so
every lapsed chat was refused, turns stayed local, and the next open found this
computer's own newer turns and opened the chat read-only. An older service
already refuses a live holder atomically and raises the fence, and the caller
checks the head the take answers, so a lapsed take is now made on every service.

`codeaf-release service` asks the live hosted service, as a throwaway identity,
for every promise this build needs and fails when one is missing. The engine's
different-environment refusal now offers `codeaf chat --no-host`, and the
`.moving` folder that holds older chats' copies carries a `README.txt`.
27 changes: 27 additions & 0 deletions internal/cell/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,30 @@ func CopyRoot(original string) string {
return filepath.Join(filepath.Dir(original), ".moving", encode128(raw))
}

// CopyFolderReadme is the note in the folder that holds moved chats. The
// folder's name reads like a transfer in progress, and removing it by hand
// would delete those chats, so the folder says what it is (#2072). The name is
// kept: older builds and copies already made look for it.
const CopyFolderReadme = "README.txt"

const copyFolderNote = `This folder holds your codeaf chats. They are the live copies codeaf writes to,
not a move in progress. Do not delete it or anything in it: that deletes those
chats. The older originals beside it stay untouched for older codeaf builds.
`

// explainCopyFolder writes the note once; an existing note is left as it is.
func explainCopyFolder(dir string) error {
f, err := os.OpenFile(filepath.Join(dir, CopyFolderReadme), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if errors.Is(err, fs.ErrExist) {
return nil
}
if err != nil {
return err
}
_, err = f.WriteString(copyFolderNote)
return errors.Join(err, f.Close())
}

// Copied resolves only a fully prepared, durable copy. A crash before the
// completion marker leaves the original as the only visible conversation.
func Copied(original string) string {
Expand Down Expand Up @@ -55,6 +79,9 @@ func CopyLegacy(ctx context.Context, original string, prepare func(context.Conte
if err := os.MkdirAll(filepath.Dir(root), 0700); err != nil {
return Cell{}, err
}
if err := explainCopyFolder(filepath.Dir(root)); err != nil {
return Cell{}, err
}
lock, err := os.OpenFile(root+".lock", os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return Cell{}, err
Expand Down
15 changes: 15 additions & 0 deletions internal/cell/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -181,3 +182,17 @@ func TestC29LinkedFolderCannotAliasTheOriginal(t *testing.T) {
t.Fatal("copy through a folder link changed the original")
}
}

// TestTheCopyFolderSaysItIsTheLiveStore pins #2072: the folder holding moved
// chats is named for a move in progress, so it says, to anyone who looks, that
// it holds the chats themselves and must not be removed.
func TestTheCopyFolderSaysItIsTheLiveStore(t *testing.T) {
original := olderFixture(t)
if _, err := CopyLegacy(t.Context(), original, func(context.Context, Cell) error { return nil }); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(filepath.Dir(CopyRoot(original)), CopyFolderReadme))
if err != nil || !strings.Contains(string(raw), "Do not delete it") {
t.Fatalf("the copy folder does not explain itself: %q, %v", raw, err)
}
}
31 changes: 31 additions & 0 deletions internal/cellstore/published.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,37 @@ func KnowsHead(c cell.Cell, head string) (bool, error) {
return false, err
}

// Extends says whether this computer's newest turn is head or was sealed on
// top of it. A computer whose own turns did not reach the service (it ran
// unsynced, or its last upload died with it) still owns that work: publishing
// it continues the record's history instead of replacing it. An adopted head
// has no line in the log, but its first local child names it as the parent.
func Extends(c cell.Cell, head string) (bool, error) {
local, err := Head(c)
if err != nil || local == nil {
return head == "", err
}
turns, err := Turns(c)
if err != nil {
return false, err
}
parent := make(map[string]string, len(turns))
for _, t := range turns {
parent[t.ID] = t.Parent
}
for at, seen := local.Turn.ID, 0; seen <= len(turns); seen++ {
if at == head {
return true, nil
}
next, ok := parent[at]
if !ok || next == "" {
return false, nil
}
at = next
}
return false, nil
}

// 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 {
Expand Down
Loading
Loading