daemon/view.bend

cswap-bend-cli@0.1.0.0 · 285 lines · 48 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/store.bend as Store
4import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
5import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
6import cswap-bend-cli@0.1.0.0/usage.bend as Usage
7import cswap-bend-cli@0.1.0.0/usage/cred.bend as UCred
8import cswap-bend-cli@0.1.0.0/policy/types.bend as T
9import cswap-bend-cli@0.1.0.0/policy/usage.bend as PU
10import cswap-bend-cli@0.1.0.0/policy/rank.bend as PR
11import cswap-bend-cli@0.1.0.0/daemon/rows.bend as Rows
12import cswap-bend-cli@0.1.0.0/daemon/events.bend as Ev
13import cswap-bend-cli@0.1.0.0/daemon/config.bend as Cfg
14import cswap-bend-cli@0.1.0.0/daemon/gather.bend as G
15import cswap-bend-cli@0.1.0.0/daemon/state.bend as St
16
17# Pure bridges between the daemon's data (config, state file, cache rows) and
18# the policy's types, and the other way to events.
19
20def nat(+n: U32) -> Nat:
21  U32.to_nat(n)
22
23def strategy2(+cf: Bool) -> T.Strategy:
24  match cf:
25    case True{}:
26      T.ConsumeFirst{}
27    case False{}:
28      T.Best{}
29
30def strategy_of(+s: String) -> T.Strategy:
31  strategy2(String.eq(s, "consume-first"))
32
33# serve TTL 180 s; the rest is the settings.
34def pol_cfg(+c: Cfg.Cfg) -> T.Config:
35  match c:
36    case Cfg.Cfg{th, iv, cd, hy, st, ia, ut, ms}:
37      T.Config{nat(th), ms, strategy_of(st), nat(iv), nat(cd), nat(hy), 180n, nat(ut), ia}
38
39# ---- autoswitch_state.json -> State ------------------------------------------------------------
40
41def sel_mt(+c: Bool, +x: Maybe<&2, T.Trigger>, +y: Maybe<&2, T.Trigger>) -> Maybe<&2, T.Trigger>:
42  match c:
43    case True{}:
44      x
45    case False{}:
46      y
47
48def trigger_of(+s: String) -> Maybe<&2, T.Trigger>:
49  sel_mt(String.eq(s, "proactive"), Some{T.Proactive{}},
50  sel_mt(String.eq(s, "at-limit"), Some{T.AtLimit{}},
51  sel_mt(String.eq(s, "failover"), Some{T.Failover{}},
52  sel_mt(String.eq(s, "consume-first"), Some{T.ConsumeFirstT{}}, None{}))))
53
54def num_nat(+m: Maybe<&2, Json.Json>) -> Maybe<&2, Nat>:
55  Rows.nat_opt(Rows.bind_s(Rows.num_text(m), t => Rows.epoch_text(t)))
56
57def centi_nat(+m: Maybe<&2, Json.Json>) -> Maybe<&2, Nat>:
58  Rows.nat_opt(Rows.bind_s(Rows.num_text(m), t => Cfg.centi(t)))
59
60def slot_nat(+m: Maybe<&2, Json.Json>) -> Maybe<&2, Nat>:
61  Rows.nat_opt(Rows.bind_s(Rows.str_text(m), t => Cfg.read_u32(t)))
62
63def snap2(+tr: Maybe<&2, Json.Json>, +st: Json.Json) -> T.Snap:
64  match tr:
65    case Some{Json.JStr{s}}:
66      T.Snap{centi_nat(Json.get("leftHeadroom", st)), num_nat(Json.get("leftRecoveryAt", st)), trigger_of(s)}
67    case _:
68      T.NoSnap{}
69
70def snap_of(+st: Json.Json) -> T.Snap:
71  snap2(Json.get("leftTrigger", st), st)
72
73def state_of(+st: Json.Json) -> T.State:
74  T.State{Rows.nat_opt(St.last_switch_at(st)), slot_nat(Json.get("lastSwitchTo", st)), num_nat(Json.get("lastSwitchFrom", st)), snap_of(st)}
75
76# ---- accounts and usages ------------------------------------------------------------------------
77
78def accounts_of(+es: List<&2, G.Ent>) -> List<&2, T.Account>:
79  match es:
80    case Nil{}:
81      []
82    case Con{G.Ent{s, e, o, c, a}, t}:
83      a <> accounts_of(t)
84
85def oauth_of(+creds: String) -> Json.Json:
86  SSeq.get_or("claudeAiOauth", Json.unwrap(Json.parse(creds)), Json.JNull{})
87
88def sel_sn(+c: Bool, +x: T.Sentinel, +y: T.Sentinel) -> T.Sentinel:
89  match c:
90    case True{}:
91      x
92    case False{}:
93      y
94
95def sent2(+is_active: Bool, +has: Bool, +api: Bool, +creds: String, +now: U32) -> T.Sentinel:
96  match has api:
97    case False{} _:
98      T.OtherSentinel{}
99    case True{} True{}:
100      T.OtherSentinel{}
101    case True{} False{}:
102      sel_sn(is_active && UCred.expired(oauth_of(creds), now), T.TokenExpired{}, T.NoSentinel{})
103
104# The overlay that hides a row: an expired token on the active account (idle
105# Claude Code), or an account with no usable credential or no quota to read.
106def sentinel_of(+is_active: Bool, +e: G.Ent, +now: U32) -> T.Sentinel:
107  match e:
108    case G.Ent{slot, email, org, +creds, acct}:
109      sent2(is_active, SBase.nonempty(creds), SBase.looks_like_api_key(creds), creds, now)
110
111def row_guard(+row: Json.Json, +email: String, +org: String) -> Json.Json:
112  Store.sel.j(String.eq(SSeq.field_str(row, "email"), email) && String.eq(SSeq.field_str(row, "organizationUuid"), org), row, Json.JNull{})
113
114def row_of(+rows: Json.Json, +e: G.Ent) -> Json.Json:
115  match e:
116    case G.Ent{slot, email, org, creds, acct}:
117      row_guard(SSeq.get_or(slot, rows, Json.JNull{}), email, org)
118
119def is_slot(+active: Maybe<&2, String>, +slot: String) -> Bool:
120  match active:
121    case Some{a}:
122      String.eq(a, slot)
123    case None{}:
124      False{}
125
126def usage_one(+rows: Json.Json, +e: G.Ent, +active: Maybe<&2, String>, +now: U32) -> T.Usage:
127  match e:
128    case G.Ent{+slot, +email, +org, +creds, +acct}:
129      Rows.usage_of(nat(SSeq.str_u32(slot)), row_of(rows, e), sentinel_of(is_slot(active, slot), e, now))
130
131def usages_of(+rows: Json.Json, +es: List<&2, G.Ent>, +active: Maybe<&2, String>, +now: U32) -> List<&2, T.Usage>:
132  match es:
133    case Nil{}:
134      []
135    case Con{e, t}:
136      usage_one(rows, e, active, now) <> usages_of(rows, t, active, now)
137
138def sel_mu(+c: Bool, +x: Maybe<&2, T.Usage>, +y: Maybe<&2, T.Usage>) -> Maybe<&2, T.Usage>:
139  match c:
140    case True{}:
141      x
142    case False{}:
143      y
144
145def usage_pick(+u: T.Usage, +slot: Nat, +rest: Maybe<&2, T.Usage>) -> Maybe<&2, T.Usage>:
146  match u:
147    case T.Usage{+s, five, fr, seven, sr, ss, fa, bo, er, fl, np, pi, ad, hu, cu, se}:
148      sel_mu(Nat.is_eq(s, slot), Some{u}, rest)
149
150def usage_for(+us: List<&2, T.Usage>, +slot: Nat) -> Maybe<&2, T.Usage>:
151  match us:
152    case Nil{}:
153      None{}
154    case Con{u, t}:
155      usage_pick(u, slot, usage_for(t, slot))
156
157def sel_me(+c: Bool, +x: Maybe<&2, G.Ent>, +y: Maybe<&2, G.Ent>) -> Maybe<&2, G.Ent>:
158  match c:
159    case True{}:
160      x
161    case False{}:
162      y
163
164def ent_pick(+e: G.Ent, +slot: String, +rest: Maybe<&2, G.Ent>) -> Maybe<&2, G.Ent>:
165  match e:
166    case G.Ent{+s, em, o, c, a}:
167      sel_me(String.eq(s, slot), Some{e}, rest)
168
169def ent_for(+es: List<&2, G.Ent>, +slot: String) -> Maybe<&2, G.Ent>:
170  match es:
171    case Nil{}:
172      None{}
173    case Con{e, t}:
174      ent_pick(e, slot, ent_for(t, slot))
175
176def email2(+m: Maybe<&2, G.Ent>) -> String:
177  match m:
178    case Some{G.Ent{s, em, o, c, a}}:
179      em
180    case None{}:
181      ""
182
183# ---- the poll event ----------------------------------------------------------------------------
184
185def email_of(+es: List<&2, G.Ent>, +slot: String) -> String:
186  email2(ent_for(es, slot))
187
188def scoped_keep(+hit: Bool, +w: Ev.Win, +rest: List<&2, Ev.Win>) -> List<&2, Ev.Win>:
189  match hit:
190    case True{}:
191      w <> rest
192    case False{}:
193      rest
194
195def scoped_wins(+ss: List<&2, T.Scoped>, +ms: List<&2, String>, +now: Nat) -> List<&2, Ev.Win>:
196  match ss:
197    case Nil{}:
198      []
199    case Con{T.Scoped{n, p, r}, t}:
200      scoped_keep(PU.model.wanted(String.to_lower(n), ms), Ev.Win{n, U32.from_nat(p)}, scoped_wins(t, ms, now))
201
202def opt_win(+name: String, +m: Maybe<&2, Nat>, +rest: List<&2, Ev.Win>) -> List<&2, Ev.Win>:
203  match m:
204    case Some{p}:
205      Ev.Win{name, U32.from_nat(p)} <> rest
206    case None{}:
207      rest
208
209def win_rows(+u: T.Usage, +ms: List<&2, String>, +now: Nat) -> List<&2, Ev.Win>:
210  match u:
211    case T.Usage{s, five, fr, seven, sr, ss, fa, bo, er, fl, np, pi, ad, hu, cu, se}:
212      opt_win("5h", five, opt_win("7d", seven, scoped_wins(ss, ms, now)))
213
214def head_u32(+m: Maybe<&2, Nat>) -> Maybe<&2, U32>:
215  match m:
216    case Some{n}:
217      Some{U32.from_nat(n)}
218    case None{}:
219      None{}
220
221def err_text(+row: Json.Json) -> Maybe<&2, String>:
222  Rows.str_text(Json.get("lastError", row))
223
224def sel_wl(+c: Bool, +x: List<&2, Ev.Win>, +y: List<&2, Ev.Win>) -> List<&2, Ev.Win>:
225  match c:
226    case True{}:
227      x
228    case False{}:
229      y
230
231def poll_row2(+slot: String, +trusted: Bool, +u: T.Usage, +err: Maybe<&2, String>, +ms: List<&2, String>, +now: Nat) -> Ev.Row:
232  Ev.Row{slot, head_u32(PU.headroom(u, now, ms)), sel_wl(trusted, win_rows(u, ms, now), []), err}
233
234# One poll row: windows only for a trusted reading; the error only when the
235# usage is unknown.
236def poll_row(+e: G.Ent, +u: T.Usage, +rows: Json.Json, +ms: List<&2, String>, +now: Nat) -> Ev.Row:
237  match e:
238    case G.Ent{+slot, email, org, creds, acct}:
239      poll_row2(slot, PU.trusted(u, now, ms), u, err_text(row_of(rows, e)), ms, now)
240
241def row_opt(+e: G.Ent, +u: Maybe<&2, T.Usage>, +rows: Json.Json, +ms: List<&2, String>, +now: Nat) -> Maybe<&2, Ev.Row>:
242  match u:
243    case Some{x}:
244      Some{poll_row(e, x, rows, ms, now)}
245    case None{}:
246      None{}
247
248def push_row(+m: Maybe<&2, Ev.Row>, +rest: List<&2, Ev.Row>) -> List<&2, Ev.Row>:
249  match m:
250    case Some{r}:
251      r <> rest
252    case None{}:
253      rest
254
255def ent_slot(+e: G.Ent) -> String:
256  match e:
257    case G.Ent{s, em, o, c, a}:
258      s
259
260def poll_rows(+es: List<&2, G.Ent>, +us: List<&2, T.Usage>, +rows: Json.Json, +ms: List<&2, String>, +now: Nat) -> List<&2, Ev.Row>:
261  match es:
262    case Nil{}:
263      []
264    case Con{e, t}:
265      push_row(row_opt(e, usage_for(us, nat(SSeq.str_u32(ent_slot(e)))), rows, ms, now), poll_rows(t, us, rows, ms, now))
266
267def slot_nat_of(+s: String) -> Nat:
268  nat(SSeq.str_u32(s))
269
270# {"number": N, "email": ...} for a slot of the roster.
271def ref_json(+slot: String, +es: List<&2, G.Ent>) -> Json.Json:
272  Ev.ref(slot, email_of(es, slot))
273
274def opt_json(+m: Maybe<&2, String>) -> Json.Json:
275  match m:
276    case Some{x}:
277      Json.JStr{x}
278    case None{}:
279      Json.JNull{}
280
281def snap_accounts(+s: G.Snap) -> List<&2, T.Account>:
282  accounts_of(G.snap_ents(s))
283
284def snap_state(+s: G.Snap) -> T.State:
285  state_of(G.snap_state(s))