Skip to content
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,27 @@ Basecamp's HTTP handshake. Do not reconnect just to upgrade the CLI.
Each profile has its own projects, ledger, cursor, hold and local lock. When
two connections serve the same project, a request both admit can be answered
twice, so serve each project from one. Use a distinct `--device-name` for each
profile on the same computer. Where Basecamp lists several connections, you
profile on the same computer.

Only one computer should answer an agent's Pings. A Ping is in no project, so
every connector for the agent sees all of them, and each one that heard them
would answer. A connector started with `--pings` hears Pings, and anything
else outside a project; one started with `--no-pings` hears only its projects.
Without either, the agent's first connection hears them, a connection added
after it doesn't, and a bot user's login does, as it always has. A run given
`--project` hears none and saves nothing. The first start without `--project`
saves the choice for the profile, as either flag saves its own, and restarts
keep it until you pass the other flag:

```bash
basecamp connect -P desktop --pings # this computer answers Pings
basecamp connect -P laptop --no-pings # this one leaves them to the desktop
```

A connector that hears Pings warns when another one on the same computer hears
the same agent's. It can't see other computers, so starting just one there is
yours to arrange. `basecamp connect status` and `basecamp connect doctor` say
whether a profile's connector answers them, and why. Where Basecamp lists several connections, you
can rename them and disconnect one without using Disconnect all. Reconnecting
a profile on an add-capable server leaves its old connection live until you
disconnect it; older credential files can remain live too. Do not delete them
Expand Down
40 changes: 40 additions & 0 deletions docs/agent-connections-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,46 @@ the approval page; do not reconnect merely to upgrade the CLI.
A second run of the same profile is refused. Deliberate project overlap is
allowed; requests admitted by both can be answered twice. There is no lease
or cross-profile claim.
6. Decide which one computer answers the agent's Pings (see below), and start
every other connector for the agent with `--no-pings` once.

## Only one computer answers Pings

A Ping is in no project, so serving projects can't divide Pings between
connections: every connector for the agent sees all of them, and each one
that heard them would answer. Only one computer should answer Pings, and the
person running the agent arranges it; the CLI picks a default, keeps the
choice, and warns about a second it can see on the same computer.

- `--pings` hears Pings and anything else outside a project. `--no-pings`
hears only the connector's projects.
- With neither, the agent's first connection (its credential is the agent
client's own secret) hears them, and a connection added after it (its
secret starts `bcac_`) doesn't. A profile that holds a person's login, a
bot user's, hears them as every connector did before this build. A run
given `--project` hears none, as before, and `--pings` with `--project` is
refused.
- The first start on this build without `--project` saves the answer in the
profile's state directory (`pings.json`, beside the ledger), as `--pings`
and `--no-pings` save theirs, and every restart keeps it until one of them
changes it. A run given `--project` saves nothing, so a narrowed first run
leaves the choice to the first one that isn't. A profile that heard Pings keeps
hearing them after a reconnect turns its credential into an added
connection. An unreadable `pings.json` refuses the start: remove it, then
choose again with a flag.
- `basecamp connect status` and `basecamp connect doctor` say whether the
profile's connector answers Pings and why. A connector that hears them
warns when another for the same agent on this computer does too, an older
build's included (it hears them unless started with `--project`).
Connectors on other computers can't be seen.
- Upgrading changes nothing for one connection per agent: it is the first
connection, or a bot user's login, and it still hears Pings. Where an agent
already runs several connectors that hear Pings (a bot user on three
computers, say), choose one, and restart the others with `--no-pings`.

Downgrading drops the choice: older builds ignore `pings.json` and every
connector they run hears Pings unless given `--project`. The file stays, and
the next start on this build reads it again.

State is under `$XDG_STATE_HOME/basecamp/connect` and `connect-shadow` (default
`~/.local/state/basecamp`). New profile directories canonicalize numeric account
Expand Down
22 changes: 19 additions & 3 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ the agent.
Connect the agent to a profile first (basecamp auth agent connect -P <profile>),
then run setup on that profile: it records who may drive the agent, maps
which Basecamp projects it serves, and checks the connector is
ready. Pings need no setup: an operator's ping is handed off too. Show prints what setup recorded. Then run the connector on it:
ready. Show prints what setup recorded. Then run the connector on it:

basecamp connect -P <profile> [--project <id>]... [--shadow]
basecamp connect -P <profile> [--project <id>]... [--pings | --no-pings] [--shadow]

It runs in the foreground until interrupted. Stdout is one JSON object per
line: events seen, admission's verdicts, and a "type":"request" line for each
Expand All @@ -69,6 +69,19 @@ a new approval replaces the existing connection. Each profile
serves its own projects and has its own hold. When two connections serve the
same project, a request both admit can be answered twice.

Pings. Only one computer should answer an agent's Pings: every connector for
the agent sees all of them, since a Ping is in no project. A connector started
with --pings hears Pings, and anything else outside a project; one started
with --no-pings hears only projects. Without either, the agent's first
connection hears them, a connection added after it doesn't, and a person's
login (a bot user's) does, as before. A run given --project hears none, and
saves nothing. The first start without --project saves the choice for the
profile, as --pings and --no-pings save theirs, and every restart keeps it
until one of them changes it. A connector that hears Pings warns when
another one on this computer hears the same agent's; ones on other computers
can't be seen, so starting just one is yours to arrange. basecamp connect
status and doctor say which this profile's does.

basecamp connect status what it heard and holds
basecamp connect doctor what it needs to run
basecamp connect discard <id> close a record without running it
Expand All @@ -78,6 +91,7 @@ same project, a request both admit can be answered twice.
basecamp connect import <file> apply a cutover reconciliation file`,
Example: ` basecamp connect setup -P agent --operator-profile me --serve 12345
basecamp connect -P agent
basecamp connect -P laptop --no-pings
basecamp connect -P agent --project 12345 --shadow`,
Args: cobra.NoArgs,
Annotations: map[string]string{
Expand Down Expand Up @@ -401,7 +415,9 @@ The list is this profile's own. When two connections of one agent serve the
same project, a request both admit there can be answered twice, so serve
each project from one of them.

Pings. A Ping is in no project, so it is never served and needs no setup.
Pings. A Ping is in no project, so it is never served and needs no setup,
and only the connector started to hear Pings answers them (see basecamp
connect --help: only one computer should answer Pings).
An operator (the operator, or a person passed with --allow) gives the agent
work by Ping in every trust mode: every line of theirs in a direct Ping with
the agent is a request, and in a group Ping a line that @mentions it.
Expand Down
88 changes: 85 additions & 3 deletions internal/commands/connect_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"time"

Expand All @@ -25,9 +26,10 @@ func newConnectDoctorCmd() *cobra.Command {
Use: "doctor",
Short: "Check what the connector needs to run",
Long: `Check the connector for a set-up profile: connect.json, the token, the agent's
identity, the stream ticket mint, the account feed, and the ledger (its gaps,
open losses, hold, and lifecycle messages an older connector left waiting for
a person).
identity, the stream ticket mint, the account feed, whether it hears the
agent's Pings (and whether another connector on this computer does too), and
the ledger (its gaps, open losses, hold, and lifecycle messages an older
connector left waiting for a person).

It writes nothing to the connector's ledger and posts nothing to Basecamp.
Renewing the profile's own credential, which every command does when its token
Expand Down Expand Up @@ -65,6 +67,7 @@ func runConnectDoctor(cmd *cobra.Command, _ []string) error {
feedCheck(ctx, agent.client.ForAccount(agent.account)),
)
}
checks = append(checks, pingsCheck(ctx, p))
checks = append(checks, ledgerChecks(ctx, p)...)

result := summarizeChecks(asDoctorChecks(checks))
Expand Down Expand Up @@ -171,3 +174,82 @@ func connectUnsupportedOSCheck(goos string) setup.Check {
Message: fmt.Sprintf("The connector does not run on %s: %s", goos, connectSupportedOSReason),
Hint: "Run the connector on Linux or macOS; the rest of the CLI runs here."}
}

// pingsCheck says whether the profile's connector hears the agent's Pings —
// the one running now, as its lock says, else a start with neither --pings,
// --no-pings nor --project — and warns when two connectors on this computer
// answer them. Only one should; the ones on other computers can't be seen
// from here. The saved choice is read either way: a running connector
// doesn't make an unreadable one the next start's to discover.
func pingsCheck(ctx context.Context, p connectProfile) setup.Check {
c := setup.Check{Name: "Pings"}
v := readConnectPingsView(ctx, p.app, p.name, p.file)
if v.SavedErr != nil {
c.Status, c.Message = setup.StatusFail, "Whether it hears the agent's Pings was saved, and can't be read, so the next start without a flag refuses: "+errorMessage(v.SavedErr)
c.Hint = v.Remedy(p.name)
return c
}
hears, known := v.Hears()
if !known {
c.Status, c.Message = setup.StatusSkip, "Whether it hears the agent's Pings can't be told without the profile's credential"
return c
}
// A running connector is described by its lock; Next may be unknown
// then, with no saved choice and no credential to read a default from.
who, why := "The connector running now", v.Running.Describe()
if !v.Running.Running {
who, why = "It", v.Next.Describe()
}
switch {
case !hears:
c.Message = who + " doesn't hear the agent's Pings: " + why
case v.Running.Running && v.Running.Pings == nil:
c.Message = who + " may hear the agent's Pings: " + why
default:
c.Message = who + " hears the agent's Pings: " + why
}
// What a restart without flags or --project would do, when it differs.
if v.Running.Running && v.Next != nil && v.Next.Hears != hears {
if v.Next.Hears {
c.Message += "; restarted without --project, it would hear them (" + v.Next.Describe() + ")"
} else {
c.Message += "; restarted, it would hear none (" + v.Next.Describe() + ")"
}
}
switch {
case v.Twice() && hears && len(v.Others) == 1:
c.Status = setup.StatusWarn
c.Message += ", and so does " + peerList(v.Others) + " on this computer: each Ping is answered twice"
case v.Twice() && hears:
c.Status = setup.StatusWarn
c.Message += ", and so do " + peerList(v.Others) + " on this computer: each Ping is answered more than once"
case v.Twice():
c.Status = setup.StatusWarn
c.Message += ", and " + peerList(v.Others) + " on this computer all do: each Ping is answered more than once"
case len(v.Others) == 1:
c.Status = setup.StatusPass
c.Message += "; " + peerList(v.Others) + " answers them on this computer"
return c
case !hears:
c.Status = setup.StatusPass
c.Message += ". Only one connector should answer them; start this one with --pings if it's that one"
return c
default:
c.Status = setup.StatusPass
return c
}
// A profile that hears none can only point at the others: restarting it
// with --no-pings changes nothing.
older := slices.ContainsFunc(v.Others, func(o connectPingsPeer) bool { return o.Older })
switch {
case !hears && older:
c.Hint = "Only one connector should answer Pings, and this one doesn't: upgrade any older one, then restart all but one of the others with --no-pings: basecamp connect -P <profile> --no-pings"
case !hears:
c.Hint = "Only one connector should answer Pings, and this one doesn't: restart all but one of the others with --no-pings: basecamp connect -P <profile> --no-pings"
case older:
c.Hint = "Only one connector should answer Pings, and an older build can't be told: upgrade it, or start this one with --no-pings: basecamp connect -P " + richtext.ShellQuote(p.name) + " --no-pings"
default:
c.Hint = "Only one connector should answer Pings. Start all but one of them with --no-pings: basecamp connect -P " + richtext.ShellQuote(p.name) + " --no-pings"
}
return c
}
97 changes: 95 additions & 2 deletions internal/commands/connect_operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ func newConnectStatusCmd() *cobra.Command {
feed position (whether one is held, never the position), the last poll-served id,
gaps and losses, queue depths, live tasks, lifecycle
messages waiting for a person, held records, and the last 20 dispatches with
their outcomes.
their outcomes. It also says whether the profile's connector answers the
agent's Pings and why, and which other connectors on this computer answer
them too: only one should.

It reads the ledger read-only and takes no lock, so it works while the
connector runs. It shows no content, no feed position and no token; a held
Expand All @@ -196,9 +198,39 @@ type connectStatusReport struct {
Profile string `json:"profile"`
Shadow bool `json:"shadow"`
LockHolder *connectLockHolder `json:"lock_holder,omitempty"`
Pings connectStatusPings `json:"pings"`
Status connector.Status `json:"status"`
}

// connectStatusPings is whether the profile's connector hears the agent's
// Pings: the choice its starts keep, the connector running now, and the
// other connectors on this computer that hear them. It is the profile's,
// read the same with --shadow: a shadow uses the profile's choice and
// answers nothing.
type connectStatusPings struct {
// Saved is the choice the profile's starts keep; nil before one has.
Saved *connector.PingsChoice `json:"saved,omitempty"`
// Error is why the saved choice can't be read, when it can't, and Hint
// how to recover.
Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
// Running is the profile's connector running now, as its lock says; nil
// when none runs.
Running *connectStatusRunning `json:"running,omitempty"`
// Others are the connectors running on this computer for the same agent,
// under other profiles, that hear its Pings, or may.
Others []connectPingsPeer `json:"others,omitempty"`
// Twice says two connectors on this computer answer them.
Twice bool `json:"twice,omitempty"`
}

// connectStatusRunning is the profile's connector running now. Hears is nil
// when an older build runs it, which doesn't say.
type connectStatusRunning struct {
Hears *bool `json:"hears,omitempty"`
Because string `json:"because,omitempty"`
}

// connectLockHolder is what a connector wrote beside its instance lock. It is
// diagnostic, not an answer: the metadata is written best effort after the
// lock is taken, it stays behind after a crash, and a pid may since belong to
Expand All @@ -210,6 +242,10 @@ type connectLockHolder struct {
// platform can give one. Present says a process has that pid, not that it
// is that connector.
PIDStatus string `json:"pid_status"`
// Pings is whether that connector hears the agent's Pings, as it wrote,
// and PingsBecause why; absent when an older build wrote the file.
Pings *bool `json:"pings,omitempty"`
PingsBecause string `json:"pings_because,omitempty"`
}

func runConnectStatus(cmd *cobra.Command, shadow bool) error {
Expand Down Expand Up @@ -242,7 +278,20 @@ func runConnectStatus(cmd *cobra.Command, shadow bool) error {
}
report := connectStatusReport{Profile: p.name, Shadow: shadow, Status: status}
if holder, ok := connector.InstanceHolder(dir, p.file.AccountID, p.file.Agent.PersonID); ok {
report.LockHolder = &connectLockHolder{PID: holder.PID, StartedAt: holder.StartedAt, PIDStatus: processPresence(holder.PID)}
report.LockHolder = &connectLockHolder{PID: holder.PID, StartedAt: holder.StartedAt, PIDStatus: processPresence(holder.PID), Pings: holder.Pings, PingsBecause: holder.PingsBecause}
}
// Status reads no credential, so a profile that has saved no choice has
// none to show.
pings := readConnectPingsView(cmd.Context(), nil, p.name, p.file)
report.Pings = connectStatusPings{Saved: pings.Saved, Others: pings.Others, Twice: pings.Twice()}
if pings.SavedErr != nil {
report.Pings.Error, report.Pings.Hint = errorMessage(pings.SavedErr), pings.Remedy(p.name)
}
if r := pings.Running; r.Running {
report.Pings.Running = &connectStatusRunning{}
if r.Pings != nil {
report.Pings.Running.Hears, report.Pings.Running.Because = &r.Pings.Hears, r.Pings.Because
}
}
if p.app.Output.EffectiveFormat() == output.FormatStyled {
renderConnectStatus(cmd.OutOrStdout(), report)
Expand Down Expand Up @@ -299,6 +348,7 @@ func renderConnectStatus(w io.Writer, r connectStatusReport) {
}
fmt.Fprintln(w)
}
renderConnectStatusPings(w, r)
if s.Hold != nil {
fmt.Fprintf(w, " Hold set by %s at %s (%s, generation %d): nothing dispatches or posts until release\n",
clean(s.Hold.HeldBy), stamp(s.Hold.HeldAt), clean(s.Hold.Cause), s.Hold.Generation)
Expand Down Expand Up @@ -805,3 +855,46 @@ func readReconciliation(path string) ([]byte, error) {
}
return data, nil
}

// renderConnectStatusPings is status's Pings lines: whether the profile's
// connector hears them and why, what the running one does when that differs,
// and who else on this computer hears them.
func renderConnectStatusPings(w io.Writer, r connectStatusReport) {
p := r.Pings
switch {
case p.Error != "":
fmt.Fprintf(w, " Pings the saved choice can't be read (%s), so the next start without a flag refuses. %s\n", richtext.SanitizeSingleLine(p.Error), p.Hint)
case p.Saved == nil:
fmt.Fprintf(w, " Pings not chosen yet: the first start without --project chooses (an agent's first connection hears them, a connection added after it doesn't)\n")
default:
fmt.Fprintf(w, " Pings %s\n", connectPingsWords(connectPings{Hears: p.Saved.Hears, Because: p.Saved.Because}))
}
if run := p.Running; run != nil {
switch {
case run.Hears == nil:
fmt.Fprintf(w, " the connector running now may hear them: %s\n", connectRunning{Running: true}.Describe())
case p.Saved == nil || *run.Hears != p.Saved.Hears:
now := connectPings{Hears: *run.Hears, Because: run.Because}
heard := "hears none"
if now.Hears {
heard = "hears them"
}
fmt.Fprintf(w, " the connector running now %s: %s\n", heard, now.Describe())
}
}
for _, other := range p.Others {
if p.Twice {
fmt.Fprintf(w, " Pings heard by %s (pid %d) on this computer too: only one connector should answer them\n", other.Words(), other.PID)
} else {
fmt.Fprintf(w, " Pings answered on this computer by %s (pid %d)\n", other.Words(), other.PID)
}
}
}

// connectPingsWords is whether a connector hears Pings, and why, for a line.
func connectPingsWords(p connectPings) string {
if p.Hears {
return "heard: " + p.Describe()
}
return "not heard: " + p.Describe() + "; start with --pings to answer them from here"
}
Loading
Loading