daemon/tick.bend

cswap-bend-cli@0.1.0.0 · 416 lines · 73 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/policy/algo.bend as A
3import cswap-bend-cli@0.1.0.0/json.bend as Json
4import cswap-bend-cli@0.1.0.0/act.bend as Act
5import cswap-bend-cli@0.1.0.0/clock.bend as Clock
6import cswap-bend-cli@0.1.0.0/store.bend as Store
7import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
8import cswap-bend-cli@0.1.0.0/swlive.bend as L
9import cswap-bend-cli@0.1.0.0/policy/types.bend as T
10import cswap-bend-cli@0.1.0.0/policy/usage.bend as PU
11import cswap-bend-cli@0.1.0.0/policy/rank.bend as PR
12import cswap-bend-cli@0.1.0.0/policy/decide.bend as D
13import cswap-bend-cli@0.1.0.0/daemon/events.bend as Ev
14import cswap-bend-cli@0.1.0.0/daemon/cadence.bend as Cad
15import cswap-bend-cli@0.1.0.0/daemon/config.bend as Cfg
16import cswap-bend-cli@0.1.0.0/daemon/gather.bend as G
17import cswap-bend-cli@0.1.0.0/daemon/view.bend as V
18import cswap-bend-cli@0.1.0.0/daemon/plan.bend as Plan
19import cswap-bend-cli@0.1.0.0/daemon/dec.bend as Dc
20import cswap-bend-cli@0.1.0.0/daemon/status.bend as Stat
21
22# One tick of `cswap auto` (autoswitch.py _tick_inner): look at the roster
23# and the live login, fetch what is due, print the poll, ask the policy what
24# to do, and act. Everything it prints is an event (a Json), returned in order.
25
26type Env is Data:
27  Env{ctx: SBase.Ctx, cfg: Cfg.Cfg, dry_run: Bool, opts: L.Opts, mem: T.Mem, checked: Bool, skip: List<&2, String>}
28
29# The tick's events and sleeper facts, the engine's counters for the next tick,
30# and what the status file shows.
31type Out is Data:
32  Out{res: Dc.TickRes, mem: T.Mem, active: Maybe<&2, String>, entries: List<&2, Stat.Entry>, accts: Json.Json, checked: Bool}
33
34# Either the tick is over (nothing to decide) or the decision is ready to act
35# on; `out` carries the poll events so far.
36type Next is Data:
37  NDone{out: Out}
38  NAct{out: Out, ctx: Dc.Ctx, dec: Dc.Dec}
39
40# ---- nobody to watch -----------------------------------------------------------------------------
41
42def env_ctx(e: Env) -> SBase.Ctx:
43  match e:
44    case Env{c, cf, d, o, m, k, sk}:
45      c
46
47def env_cfg(e: Env) -> Cfg.Cfg:
48  match e:
49    case Env{c, cf, d, o, m, k, sk}:
50      cf
51
52def env_dry(e: Env) -> Bool:
53  match e:
54    case Env{c, cf, d, o, m, k, sk}:
55      d
56
57def env_opts(e: Env) -> L.Opts:
58  match e:
59    case Env{c, cf, d, o, m, k, sk}:
60      o
61
62def env_checked(e: Env) -> Bool:
63  match e:
64    case Env{c, cf, d, o, m, k, sk}:
65      k
66
67def env_skip(e: Env) -> List<&2, String>:
68  match e:
69    case Env{c, cf, d, o, m, k, sk}:
70      sk
71
72def with_skip(e: Env, +skip: List<&2, String>) -> Env:
73  match e:
74    case Env{c, cf, d, o, m, k, sk}:
75      Env{c, cf, d, o, m, k, skip}
76
77def env_mem(e: Env) -> T.Mem:
78  match e:
79    case Env{c, cf, d, o, m, k, sk}:
80      m
81
82def idle_detail(+live: Bool) -> String:
83  match live:
84    case True{}:
85      "run 'cswap --add-account' to include it in rotation"
86    case False{}:
87      "log in and run 'cswap --add-account' first"
88
89def idle_reason(+live: Bool) -> String:
90  match live:
91    case True{}:
92      "unmanaged-active-account"
93    case False{}:
94      "no-active-account"
95
96def idle_events(+ts: String, +thr: U32, +live: Bool) -> List<&2, Json.Json>:
97  [Ev.poll(ts, Json.JNull{}, [], thr), Ev.no_switch(ts, idle_reason(live), idle_detail(live))]
98
99def idle_out(+env: Env, +ts: String, +live: Bool) -> Out:
100  Out{Dc.TickRes{Cad.Info{Cad.NoAction{}, None{}, False{}, False{}, None{}}, idle_events(ts, Cfg.cfg_threshold(env_cfg(env)), live)}, env_mem(env), None{}, [], Json.arr([]), env_checked(env)}
101
102def has_live(+s: G.Snap) -> Bool:
103  match s:
104    case G.Snap{seq, +live, active, st, rows, ents}:
105      SBase.nonempty(L.lv_email(live)) || SBase.nonempty(L.lv_creds(live))
106
107# ---- what the policy said --------------------------------------------------------------------------
108
109def secs(+n: Nat) -> U32:
110  U32.from_nat(n)
111
112def opt_secs(+m: Maybe<&2, Nat>) -> Maybe<&2, U32>:
113  match m:
114    case Some{n}:
115      Some{secs(n)}
116    case None{}:
117      None{}
118
119def exhausted_dec(+e: Maybe<&2, Nat>) -> Dc.Dec:
120  Dc.DExhausted{opt_secs(e)}
121
122def left_headroom(+a: Maybe<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Maybe<&2, U32>:
123  match a:
124    case Some{u}:
125      V.head_u32(PU.headroom(u, now, ms))
126    case None{}:
127      None{}
128
129# ---- the active account ----------------------------------------------------------------------------
130
131def dec_of(+d: T.Decision, +es: List<&2, G.Ent>, +active: Maybe<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Dc.Dec:
132  match d:
133    case T.NoSwitch{T.AllExhausted{e}, detail}:
134      exhausted_dec(e)
135    case T.NoSwitch{r, detail}:
136      Dc.DNone{T.reason.name(r), detail}
137    case T.Switch{+to, trig, why, h, mode, rec, bar}:
138      Dc.DGo{Nat.show(to), V.email_of(es, Nat.show(to)), T.trigger.name(trig), left_headroom(active, now, ms), opt_secs(PR.rec.row(active, now, ms))}
139
140def active_nat(+a: String) -> Nat:
141  V.slot_nat_of(a)
142
143# ---- one tick -----------------------------------------------------------------------------------------
144
145def next_poll_of(+m: Maybe<&2, T.Usage>) -> Maybe<&2, U32>:
146  match m:
147    case Some{T.Usage{s, five, fr, seven, sr, ss, fa, bo, er, fl, np, pi, ad, hu, cu, se}}:
148      opt_secs(np)
149    case _:
150      None{}
151
152def iso_opt(+m: Maybe<&2, Nat>) -> Maybe<&2, String>:
153  match m:
154    case Some{n}:
155      Some{Clock.iso8601_utc(secs(n))}
156    case None{}:
157      None{}
158
159def sel_bo(+c: Bool, +x: Maybe<&2, String>, +y: Maybe<&2, String>) -> Maybe<&2, String>:
160  match c:
161    case True{}:
162      x
163    case False{}:
164      y
165
166def backoff_iso(+u: T.Usage, +now: Nat) -> Maybe<&2, String>:
167  match u:
168    case T.Usage{s, five, fr, seven, sr, ss, fa, +bo, er, fl, np, pi, ad, hu, cu, se}:
169      sel_bo(Nat.is_gt(bo, now), Some{Clock.iso8601_utc(secs(bo))}, None{})
170
171def opt_u(+m: Maybe<&2, Nat>) -> Maybe<&2, U32>:
172  V.head_u32(m)
173
174def entry_of(+u: T.Usage, +now: Nat, +ms: List<&2, String>, +rows: Json.Json, +e: G.Ent) -> Stat.Entry:
175  match u:
176    case T.Usage{+s, +five, fr, +seven, sr, ss, +fa, bo, er, fl, np, pi, ad, hu, cu, se}:
177      Stat.Entry{Nat.show(s), opt_u(five), opt_u(seven), iso_opt(fa), backoff_iso(u, now), V.err_text(V.row_of(rows, e))}
178
179def entry_push(+m: Maybe<&2, T.Usage>, +e: G.Ent, +rows: Json.Json, +now: Nat, +ms: List<&2, String>, +rest: List<&2, Stat.Entry>) -> List<&2, Stat.Entry>:
180  match m:
181    case Some{u}:
182      entry_of(u, now, ms, rows, e) <> rest
183    case None{}:
184      rest
185
186def entries_of(+es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +rows: Json.Json, +now: Nat, +ms: List<&2, String>) -> List<&2, Stat.Entry>:
187  match es:
188    case Nil{}:
189      []
190    case Con{e, t}:
191      entry_push(V.usage_for(us, V.slot_nat_of(V.ent_slot(e))), e, rows, now, ms, entries_of(t, us, rows, now, ms))
192
193def poll_event(+ts: String, +active: String, +s: G.Snap, +us: List<&2, T.Usage>, +rows: Json.Json, +now: U32, +cfg: Cfg.Cfg) -> Json.Json:
194  Ev.poll(ts, V.ref_json(active, G.snap_ents(s)), V.poll_rows(G.snap_ents(s), us, rows, Cfg.cfg_models(cfg), V.nat(now)), Cfg.cfg_threshold(cfg))
195
196def prefix_poll(+r: Dc.TickRes, +ts: String, +active: String, +s: G.Snap, +us: List<&2, T.Usage>, +rows: Json.Json, +now: U32, +cfg: Cfg.Cfg, +warn: List<&2, Json.Json>) -> Dc.TickRes:
197  match r:
198    case Dc.TickRes{info, evs}:
199      Dc.TickRes{info, poll_event(ts, active, s, us, rows, now, cfg) <> List.append(&2, Json.Json, warn, evs)}
200
201def attempted2(+m: Maybe<&2, Json.Json>, +now: U32) -> Bool:
202  match m:
203    case Some{Json.JNum{t}}:
204      String.eq(Maybe.default(&2, String, List.head(&2, String, String.split(t, '.')), ""), U32.show(now))
205    case _:
206      False{}
207
208def attempted_now(+row: Json.Json, +now: U32) -> Bool:
209  attempted2(Json.get("lastAttemptAt", row), now)
210
211def ent_kind(+e: G.Ent) -> T.AcctKind:
212  match e:
213    case G.Ent{s, em, o, c, T.Account{n, ae, k, d}}:
214      k
215
216def opt_pct(+m: Maybe<&2, Nat>) -> Json.Json:
217  match m:
218    case Some{p}:
219      Ev.fl(secs(p))
220    case None{}:
221      Json.JNull{}
222
223# "usage-endpoint" or "header-probe" when this tick fetched the account (a
224# setup-token is read by the probe), "cache" otherwise.
225def source_of(+attempted_now: Bool, +k: T.AcctKind) -> String:
226  match attempted_now k:
227    case False{} _:
228      "cache"
229    case True{} T.SetupToken{}:
230      "header-probe"
231    case True{} _:
232      "usage-endpoint"
233
234def log_one(+e: G.Ent, +u: T.Usage, +rows: Json.Json, +now: U32, +ms: List<&2, String>) -> Json.Json:
235  match u:
236    case T.Usage{s, five, fr, seven, sr, ss, fa, bo, er, fl, np, pi, ad, hu, cu, se}:
237      Json.JObj{Json.JField{"slot", Json.JNum{Nat.show(s)}, Json.JField{"source", Json.JStr{source_of(attempted_now(V.row_of(rows, e), now), ent_kind(e))},
238        Json.JField{"pct_5h", opt_pct(five), Json.JField{"pct_7d", opt_pct(seven), Json.JField{"error", V.opt_json(V.err_text(V.row_of(rows, e))), Json.JNil{}}}}}}}
239
240def log_push(+m: Maybe<&2, T.Usage>, +e: G.Ent, +rows: Json.Json, +now: U32, +ms: List<&2, String>, +rest: List<&2, Json.Json>) -> List<&2, Json.Json>:
241  match m:
242    case Some{u}:
243      log_one(e, u, rows, now, ms) <> rest
244    case None{}:
245      rest
246
247def log_accts(+es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +rows: Json.Json, +now: U32, +ms: List<&2, String>) -> List<&2, Json.Json>:
248  match es:
249    case Nil{}:
250      []
251    case Con{e, t}:
252      log_push(V.usage_for(us, V.slot_nat_of(V.ent_slot(e))), e, rows, now, ms, log_accts(t, us, rows, now, ms))
253
254def readable2(+m: Maybe<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Bool:
255  match m:
256    case Some{u}:
257      PU.trusted(u, now, ms)
258    case None{}:
259      False{}
260
261def readable(+e: G.Ent, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Bool:
262  readable2(V.usage_for(us, V.slot_nat_of(V.ent_slot(e))), now, ms)
263
264def is_api(+k: T.AcctKind) -> Bool:
265  match k:
266    case T.ApiKey{}:
267      True{}
268    case _:
269      False{}
270
271def watched(+e: G.Ent) -> Bool:
272  Plan.eligible(e) && Bool.not(is_api(ent_kind(e)))
273
274# Every account the guard looks at (switchable, not quarantined, not an API
275# key) has a trusted reading.
276def all_readable(+es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +now: Nat, +ms: List<&2, String>) -> Bool:
277  match es:
278    case Nil{}:
279      True{}
280    case Con{e, t}:
281      Bool.not(watched(e)) || (readable(e, us, now, ms) && all_readable(t, us, now, ms))
282
283def keep_name(+is_all: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>:
284  match is_all:
285    case True{}:
286      rest
287    case False{}:
288      h <> rest
289
290def lower_names(+ms: List<&2, String>) -> List<&2, String>:
291  match ms:
292    case Nil{}:
293      []
294    case Con{h, t}:
295      keep_name(String.eq(String.to_lower(h), "all"), h, lower_names(t))
296
297def checked_after(+env: Env, +es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +now: Nat) -> Bool:
298  env_checked(env) || List.is_empty(&2, String, lower_names(Cfg.cfg_models(env_cfg(env)))) || all_readable(es, us, now, Cfg.cfg_models(env_cfg(env)))
299
300def check_ready(+env: Env, +es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +now: Nat) -> Bool:
301  Bool.not(env_checked(env)) && Bool.not(List.is_empty(&2, String, lower_names(Cfg.cfg_models(env_cfg(env))))) && all_readable(es, us, now, Cfg.cfg_models(env_cfg(env)))
302
303def miss_pick(+found: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>:
304  match found:
305    case True{}:
306      rest
307    case False{}:
308      h <> rest
309
310def missing(+wanted: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
311  match wanted:
312    case Nil{}:
313      []
314    case Con{h, t}:
315      miss_pick(PU.model.wanted(String.to_lower(h), seen), h, missing(t, seen))
316
317def scoped_names(+ss: List<&2, T.Scoped>) -> List<&2, String>:
318  match ss:
319    case Nil{}:
320      []
321    case Con{T.Scoped{n, p, r}, t}:
322      String.to_lower(n) <> scoped_names(t)
323
324def names_of(+m: Maybe<&2, T.Usage>) -> List<&2, String>:
325  match m:
326    case Some{T.Usage{s, five, fr, seven, sr, ss, fa, bo, er, fl, np, pi, ad, hu, cu, se}}:
327      scoped_names(ss)
328    case None{}:
329      []
330
331def seen_names(+es: List<&2, G.Ent>, +us: List<&2, T.Usage>) -> List<&2, String>:
332  match es:
333    case Nil{}:
334      []
335    case Con{e, t}:
336      List.append(&2, String, names_of(V.usage_for(us, V.slot_nat_of(V.ent_slot(e)))), seen_names(t, us))
337
338def warn_text(+names: List<&2, String>) -> String:
339  "autoswitch.model: " ++ String.join(names, ", ") ++ " matches no account's usage windows — only the 5h/7d limits are being watched for it (typo?)"
340
341def warn_events(+ts: String, +miss: List<&2, String>) -> List<&2, Json.Json>:
342  match miss:
343    case Nil{}:
344      []
345    case Con{h, t}:
346      [Ev.config_warning(ts, warn_text(Con{h, t}))]
347
348def warn2(+ready: Bool, env: Env, +es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +ts: String) -> List<&2, Json.Json>:
349  match ready:
350    case True{}:
351      warn_events(ts, missing(lower_names(Cfg.cfg_models(env_cfg(env))), seen_names(es, us)))
352    case False{}:
353      []
354
355def model_warning(+env: Env, +es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +now: Nat, +ts: String) -> List<&2, Json.Json>:
356  warn2(check_ready(env, es, us, now), env, es, us, ts)
357
358def apply_ctx(+env: Env, +s: G.Snap, +active: String, +us: List<&2, T.Usage>, +now: U32, +ts: String) -> Dc.Ctx:
359  Dc.Ctx{env_ctx(env), env_dry(env), env_cfg(env), now, ts, env_opts(env), V.ref_json(active, G.snap_ents(s)), next_poll_of(V.usage_for(us, active_nat(active)))}
360
361def to_dec(env: Env, +s: G.Snap, +active: String, +us: List<&2, T.Usage>, +now: U32, +d: T.Decision) -> Dc.Dec:
362  dec_of(d, G.snap_ents(s), V.usage_for(us, active_nat(active)), V.nat(now), Cfg.cfg_models(env_cfg(env)))
363
364def mem_next(env: Env, +s: G.Snap, +active: String, +us: List<&2, T.Usage>, +now: U32, +pcfg: T.Config) -> T.Mem:
365  D.mem_after(active_nat(active), V.accounts_of(G.snap_ents(s)), us, V.nat(now), pcfg, env_mem(env))
366
367def entries_for(env: Env, +s: G.Snap, +us: List<&2, T.Usage>, +rows: Json.Json, +now: U32) -> List<&2, Stat.Entry>:
368  entries_of(G.snap_ents(s), us, rows, V.nat(now), Cfg.cfg_models(env_cfg(env)))
369
370def log_for(env: Env, +s: G.Snap, +us: List<&2, T.Usage>, +rows: Json.Json, +now: U32) -> Json.Json:
371  Json.arr(log_accts(G.snap_ents(s), us, rows, now, Cfg.cfg_models(env_cfg(env))))
372
373def lead_res(+env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String, +us: List<&2, T.Usage>, r: Dc.TickRes) -> Dc.TickRes:
374  prefix_poll(r, ts, active, s, us, rows, now, env_cfg(env), model_warning(env, G.snap_ents(s), us, V.nat(now), ts))
375
376def made_out(+env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String, +us: List<&2, T.Usage>, +pcfg: T.Config, r: Dc.TickRes) -> Out:
377  Out{lead_res(env, s, active, rows, now, ts, us, r), mem_next(env, s, active, us, now, pcfg), Some{active}, entries_for(env, s, us, rows, now), log_for(env, s, us, rows, now), checked_after(env, G.snap_ents(s), us, V.nat(now))}
378
379def lead_out(env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String, +us: List<&2, T.Usage>, +pcfg: T.Config) -> Out:
380  made_out(env, s, active, rows, now, ts, us, pcfg, Dc.TickRes{Cad.Info{Cad.NoAction{}, None{}, False{}, False{}, None{}}, []})
381
382# The tick up to the decision: the poll (and warning) events, the counters, and
383# what to do about it. Acting on it is `apply`'s business (it needs the switch).
384def decide_out3(+env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String, +us: List<&2, T.Usage>, +pcfg: T.Config, +d: T.Decision) -> Next:
385  NAct{lead_out(env, s, active, rows, now, ts, us, pcfg), apply_ctx(env, s, active, us, now, ts), to_dec(env, s, active, us, now, d)}
386
387def decide_out2(+env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String, +us: List<&2, T.Usage>, +pcfg: T.Config) -> Next:
388  decide_out3(env, s, active, rows, now, ts, us, pcfg, A.decide_cfg(Cfg.cfg_strategy(env_cfg(env)), active_nat(active), V.snap_accounts(s), us, V.nat(now), pcfg, V.snap_state(s), env_mem(env)))
389
390def decide_out(+env: Env, +s: G.Snap, +active: String, +rows: Json.Json, +now: U32, +ts: String) -> Next:
391  decide_out2(env, s, active, rows, now, ts, V.usages_of(rows, G.snap_ents(s), Some{active}, now), V.pol_cfg(env_cfg(env)))
392
393def active_out(env: Env, +s: G.Snap, +active: String, +now: U32, +ts: String) -> Next:
394  decide_out(env, s, active, G.snap_rows(s), now, ts)
395
396def snap_out2(env: Env, +now: U32, +ts: String, +s: G.Snap, +active: Maybe<&2, String>) -> Next:
397  match active:
398    case None{}:
399      NDone{idle_out(env, ts, has_live(s))}
400    case Some{a}:
401      active_out(env, s, a, now, ts)
402
403def snap_out(env: Env, +now: U32, +ts: String, +m: Maybe<&2, G.Snap>) -> Next:
404  match m:
405    case None{}:
406      NDone{idle_out(env, ts, False{})}
407    case Some{s}:
408      snap_out2(env, now, ts, s, G.snap_active(s))
409
410# One tick over a snapshot whose rows are already refreshed (see `collect`).
411def tick(+env: Env, +now: U32, +m: Maybe<&2, G.Snap>) -> Next:
412  snap_out(env, now, Clock.iso8601_utc(now), G.skip_snap(m, env_skip(env)))
413
414# ---- the structured log: where each account's reading came from -----------------------------------
415
416# ---- the one-shot model-name typo guard ----------------------------------------------------------------