eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "fmt"
8 "log"
9 "net/http"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15)
16
17// Reclaiming never-claimed spaces.
18//
19// Self-serve creation hands out a slot against the capacity cap the moment a
20// space is made. A space whose admin never completes setup — the setup invite
21// expires after 7 days — would otherwise sit there forever, holding a slot and a
22// disk. The reaper sweeps the cloud ledger for spaces older than a threshold,
23// asks the control daemon whether each was ever set up (see instanceStatus — the
24// signal is `setup_complete`, NOT member count), and deprovisions the ones that
25// never were. Deprovisioning ARCHIVES to /data/trash (deleteInstance never
26// destroys), so a false positive is recoverable; a companion trash purge
27// reclaims that archive after a longer window.
28//
29// Both are opt-in (off unless their env is set) and only ever touch spaces IN
30// the cloud ledger — operator-run instances (family.…, home, push, …) are never
31// in it, so they can never be candidates.
32
33// ---- "never claimed" signal (controld-side, root) -----------------------------
34
35// instanceStatus reads a space's claimed-ness from its DB, READ-ONLY (safe
36// against the live instance — no migrations, no writes). For a hibernating
37// space this is also wake-free by construction: an asleep instance's local DB
38// is the warm cache the activator left at its last sleep (on one box it is the
39// newest truth — S3 only ever holds what was checkpointed FROM it), so the
40// answer comes straight off the file with no socket touched. A hibernating
41// space that has NEVER woken has no DB at all — os.Stat propagates not-exist
42// and the stats handler (controld.go) answers definitionally from the registry
43// instead: members 0, never set up, no content.
44//
45// "Never claimed" is NOT "zero members": add-instance pre-seeds the admin user
46// so the setup invite has an identity to ship, and claiming BECOMES that same
47// user — so a space has exactly one member whether or not anyone ever set it up.
48// (The pre-seed happens at setup-invite mint time, i.e. against a RUNNING
49// instance: for a hibernating create that's the create-path's readiness probe —
50// its first wake — so a woken-at-create space has the one admin; a never-woken
51// one has none.) The real signal is `setup_complete`: absent until the admin
52// finishes the setup conversation (markSetupComplete), then "1". members is
53// returned too, as a belt-and-suspenders guard (don't reap if anyone beyond the
54// seeded admin joined).
55func instanceStatus(host string) (members, people int, setupComplete, hasContent bool, err error) {
56 path := filepath.Join(instanceDataDir, host+".db")
57 if _, err := os.Stat(path); err != nil {
58 return 0, 0, false, false, err // not-exist propagates → the caller answers unknown_host
59 }
60 conn, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(3000)")
61 if err != nil {
62 return 0, 0, false, false, err
63 }
64 defer conn.Close()
65 conn.SetMaxOpenConns(1)
66 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
67 defer cancel()
68 if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&members); err != nil {
69 return 0, 0, false, false, err
70 }
71 // people is the billable head count: members who aren't agents — per-seat
72 // billing must never charge for Claude or Eleven. Like the tombstone note
73 // below, this reads OTHER instances' DB files, which may predate is_agent;
74 // a failed count falls back to the full member count rather than erroring
75 // the whole stats read.
76 people = members
77 var np int
78 if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE COALESCE(is_agent, 0) = 0").Scan(&np); err == nil {
79 people = np
80 }
81 var v string
82 switch err := conn.QueryRowContext(ctx, "SELECT value FROM config WHERE key='setup_complete'").Scan(&v); err {
83 case nil, sql.ErrNoRows: // absent = never set up
84 default:
85 return 0, 0, false, false, err
86 }
87 // hasContent guards against a false positive: any message or thread means the
88 // space was actually used, so it's never reaped no matter what setup_complete
89 // says. EXISTS short-circuits, so this stays O(1) even on a large DB.
90 // NB: delete tombstones (#581) count as "content" here. That's deliberate:
91 // this runs against OTHER instances' DB files, which may predate the
92 // tombstone_of column (filtering on it would error), and a tombstone means
93 // someone really used the space — erring protective is the guard's job.
94 var hasMsg, hasThread int
95 if err := conn.QueryRowContext(ctx,
96 "SELECT EXISTS(SELECT 1 FROM messages), EXISTS(SELECT 1 FROM threads)").Scan(&hasMsg, &hasThread); err != nil {
97 return 0, 0, false, false, err
98 }
99 return members, people, v == "1", hasMsg == 1 || hasThread == 1, nil
100}
101
102// ---- trash purge (controld-side, root) ----------------------------------------
103
104// trashDirTime returns when a /data/trash entry was archived. deleteInstance
105// names them "<host>-<unix>"; trust that suffix, falling back to the dir mtime.
106func trashDirTime(name string, info os.FileInfo) time.Time {
107 if i := strings.LastIndexByte(name, '-'); i >= 0 {
108 if sec, err := strconv.ParseInt(name[i+1:], 10, 64); err == nil && sec > 0 {
109 return time.Unix(sec, 0)
110 }
111 }
112 if info != nil {
113 return info.ModTime()
114 }
115 return time.Time{}
116}
117
118// purgeTrash removes archived spaces older than keep, returning how many it
119// removed. Pure but for the filesystem, so it's directly testable.
120func purgeTrash(trashDir string, keep time.Duration, now time.Time) (int, error) {
121 entries, err := os.ReadDir(trashDir)
122 if os.IsNotExist(err) {
123 return 0, nil
124 }
125 if err != nil {
126 return 0, err
127 }
128 cutoff := now.Add(-keep)
129 removed := 0
130 for _, e := range entries {
131 if !e.IsDir() {
132 continue
133 }
134 info, _ := e.Info()
135 if trashDirTime(e.Name(), info).Before(cutoff) {
136 if err := os.RemoveAll(filepath.Join(trashDir, e.Name())); err != nil {
137 log.Printf("controld: trash purge %s: %v", e.Name(), err)
138 continue
139 }
140 removed++
141 }
142 }
143 return removed, nil
144}
145
146// runTrashPurge sweeps /data/trash once at startup then daily. keep<=0 disables it.
147func runTrashPurge(keep time.Duration) {
148 if keep <= 0 {
149 return
150 }
151 trashDir := filepath.Join(instanceDataDir, "trash")
152 log.Printf("controld: trash purge on — reclaims archived spaces older than %s", keep)
153 for {
154 if n, err := purgeTrash(trashDir, keep, time.Now()); err != nil {
155 log.Printf("controld: trash purge: %v", err)
156 } else if n > 0 {
157 log.Printf("controld: trash purge removed %d archived space(s)", n)
158 }
159 time.Sleep(24 * time.Hour)
160 }
161}
162
163// ---- the reaper (cloud-side) ---------------------------------------------------
164
165// instanceStats is what the control daemon reports about one space: the
166// reaper's guards plus the storage meter's byte count (meter.go).
167type instanceStats struct {
168 Members int `json:"members"`
169 // People is the non-agent member count — what per-seat billing charges
170 // for (bots are free). Absent from pre-people controld responses, where
171 // it decodes as 0; consumers fall back to Members.
172 People int `json:"people"`
173 SetupComplete bool `json:"setup_complete"`
174 HasContent bool `json:"has_content"`
175 StorageBytes int64 `json:"storage_bytes"`
176}
177
178// controlStats asks the control daemon for a space's stats.
179func (s *cloudServer) controlStats(host string) (instanceStats, error) {
180 var st instanceStats
181 req, err := http.NewRequest("GET", "http://control/instances/"+host+"/stats", nil)
182 if err != nil {
183 return st, err
184 }
185 req.Header.Set("Authorization", "Bearer "+s.controlToken)
186 resp, err := s.control.Do(req)
187 if err != nil {
188 return st, err
189 }
190 defer resp.Body.Close()
191 if resp.StatusCode != http.StatusOK {
192 return st, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode)
193 }
194 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
195 return st, err
196 }
197 return st, nil
198}
199
200// reapLoop sweeps every interval; enabled only when reapAfter>0.
201func (s *cloudServer) reapLoop(interval time.Duration) {
202 if s.reapAfter <= 0 {
203 return
204 }
205 mode := ""
206 if s.reapDryRun {
207 mode = " (dry-run — logs only, deletes nothing)"
208 }
209 log.Printf("cloud: reaper on%s — reclaims never-claimed spaces older than %s, sweeping every %s", mode, s.reapAfter, interval)
210 for {
211 s.reapOnce(time.Now())
212 time.Sleep(interval)
213 }
214}
215
216// reapOnce reclaims never-claimed spaces older than reapAfter. It deletes only a
217// space the control daemon confirms was NEVER set up (setup_complete absent), that
218// nobody beyond the pre-seeded admin joined (members <= 1), AND that has zero
219// content (no messages or threads) — three independent guards, so a space with any
220// real use survives all of them. Anything set up / in use / with content / whose
221// status can't be read is left untouched (a stats error is a skip, never a reap).
222// A per-sweep cap (reapMax) bounds the blast radius of any future mis-classification
223// to a handful of recoverable archives before an operator would notice. Returns counts.
224func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) {
225 cutoff := now.Add(-s.reapAfter).Unix()
226 cands, err := s.db.spacesOlderThan(cutoff)
227 if err != nil {
228 log.Printf("cloud: reaper: ledger query: %v", err)
229 return 0, 0
230 }
231 for _, c := range cands {
232 st, err := s.controlStats(c.Host)
233 if err != nil {
234 log.Printf("cloud: reaper: skip %s (stats unavailable): %v", c.Host, err)
235 continue
236 }
237 if st.SetupComplete || st.Members > 1 || st.HasContent {
238 kept++
239 continue // set up, someone else joined, or has content — never reap
240 }
241 created := time.Unix(c.CreatedAt, 0).UTC().Format(time.RFC3339)
242 if s.reapDryRun {
243 log.Printf("cloud: reaper: WOULD reclaim %s (id %d, never set up, created %s)", c.Host, c.ID, created)
244 continue
245 }
246 if s.reapMax > 0 && reaped >= s.reapMax {
247 log.Printf("cloud: reaper: per-sweep cap (%d) reached — deferring the rest to the next sweep", s.reapMax)
248 break
249 }
250 status, body, err := s.controlDelete(c.Host, false)
251 if err != nil {
252 log.Printf("cloud: reaper: delete %s failed: %v", c.Host, err)
253 continue
254 }
255 // unknown_host = already gone (an operator removed it); still clear the row.
256 if status != http.StatusOK && body["error"] != "unknown_host" {
257 log.Printf("cloud: reaper: delete %s: HTTP %d", c.Host, status)
258 continue
259 }
260 if err := s.db.deleteSpaceByID(c.ID); err != nil {
261 log.Printf("cloud: reaper: ledger delete %s (id %d): %v", c.Host, c.ID, err)
262 }
263 log.Printf("cloud: reaper: reclaimed never-claimed space %s (id %d, created %s)", c.Host, c.ID, created)
264 reaped++
265 }
266 if reaped > 0 || kept > 0 {
267 log.Printf("cloud: reaper: swept %d candidate(s) — %d reclaimed, %d still in use", len(cands), reaped, kept)
268 }
269 return reaped, kept
270}
271
272// ---- self-reclaim (the create path's escape hatch) ------------------------------
273
274// reclaimAge is how old one of the account's own spaces must be before a
275// quota-blocked create may reclaim it. Setup takes minutes, so an hour cleanly
276// separates "abandoned yesterday, retrying now" (reclaim) from "setting it up
277// in another tab right now" (never touch) — and the home page keeps the setup
278// link for continuing inside the window.
279const reclaimAge = time.Hour
280
281// reclaimOwnSpace frees at most one quota slot for (caller, requester) by
282// reclaiming their own oldest abandoned allocation: a pending row whose create
283// died between allocate and finalize, or a provisioned space that clears the
284// reaper's never-used guards (never set up, nobody joined, zero content) —
285// plus ownership and the age gate on top. Data still archives to /data/trash
286// via controld, same as the reaper. A stats error is a skip, never a reclaim.
287// Returns whether a slot was freed.
288func (s *cloudServer) reclaimOwnSpace(callerID, requester string) bool {
289 cands, err := s.db.reclaimCandidates(callerID, requester, time.Now().Add(-reclaimAge).Unix())
290 if err != nil {
291 log.Printf("cloud: self-reclaim: ledger query: %v", err)
292 return false
293 }
294 for _, c := range cands {
295 if c.Host == "" {
296 // A pending row this old is a dead create (real ones finalize in
297 // seconds); there is no instance behind it — ledger-only cleanup.
298 if err := s.db.deleteSpaceByID(c.ID); err != nil {
299 log.Printf("cloud: self-reclaim: clear stale pending row (id %d): %v", c.ID, err)
300 continue
301 }
302 log.Printf("cloud: self-reclaim: cleared stale pending allocation (id %d, requester of caller %.8s…)", c.ID, callerID)
303 return true
304 }
305 st, err := s.controlStats(c.Host)
306 if err != nil {
307 log.Printf("cloud: self-reclaim: skip %s (stats unavailable): %v", c.Host, err)
308 continue
309 }
310 if st.SetupComplete || st.Members > 1 || st.HasContent {
311 continue // in real use — the quota refusal stands
312 }
313 status, body, err := s.controlDelete(c.Host, false)
314 if err != nil {
315 log.Printf("cloud: self-reclaim: delete %s failed: %v", c.Host, err)
316 continue
317 }
318 if status != http.StatusOK && body["error"] != "unknown_host" {
319 log.Printf("cloud: self-reclaim: delete %s: HTTP %d", c.Host, status)
320 continue
321 }
322 if err := s.db.deleteSpaceByID(c.ID); err != nil {
323 log.Printf("cloud: self-reclaim: ledger delete %s (id %d): %v", c.Host, c.ID, err)
324 }
325 log.Printf("cloud: self-reclaim: reclaimed %s (id %d) — owner's own never-set-up space, making room for their new create", c.Host, c.ID)
326 return true
327 }
328 return false
329}
330