switch.bend

cswap-bend-cli@0.1.0.0 · 271 lines · 41 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/act.bend as Act
4import cswap-bend-cli@0.1.0.0/store.bend as Store
5import cswap-bend-cli@0.1.0.0/swbase.bend as B
6import cswap-bend-cli@0.1.0.0/swpick.bend as P
7import cswap-bend-cli@0.1.0.0/swlive.bend as L
8import cswap-bend-cli@0.1.0.0/swrun.bend as R
9import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
10import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
11import cswap-bend-cli@0.1.0.0/store/ops.bend as SOps
12import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
13
14# Switch, part 6: the entry points (switch_to, switch_auto).
15
16# `do ActM<A>:` (see store.bend).
17
18def ActM(-A: Type) -> Type:
19  Act.of(A)
20
21def ActM.pure(-A: Type, x: A) -> Act.of(A):
22  Act.pure(A, x)
23
24def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
25  Act.bind(A, B, m, f)
26
27def direct4(+already: Bool, +ctx: SBase.Ctx, +n: String, +force: Bool, +opts: L.Opts, +email: String) -> Act.of(Json.Json):
28  match already:
29    case True{}:
30      Act.pure(Json.Json, B.noop("direct", "already-active", "Already on Account-" ++ n ++ " (" ++ email ++ ")", B.ref(Some{n}, email), []))
31    case False{}:
32      do ActM<Json.Json>:
33        op : Json.Json <- R.do_switch(ctx, n, force, opts)
34        return B.forced_result(force, B.result_of_op(op, "direct", []))
35
36def direct3(+ctx: SBase.Ctx, +seq: Json.Json, +n: String, +force: Bool, +opts: L.Opts, +rec: Maybe<&2, Json.Json>) -> Act.of(Json.Json):
37  match rec:
38    case None{}:
39      Act.fail(Json.Json, B.e_not_found(), "Account-" ++ n ++ " does not exist")
40    case Some{r}:
41      do ActM<Json.Json>:
42        l : L.Live <- L.read_live(ctx)
43        direct4(Bool.not(force) && String.eq(SSeq.str_or(L.lv_slot(l, seq), ""), n), ctx, n, force, opts, SSeq.field_str(r, "email"))
44
45def direct2(+ctx: SBase.Ctx, +seq: Json.Json, +ident: String, +force: Bool, +opts: L.Opts, +r: SSeq.Resolved) -> Act.of(Json.Json):
46  match r:
47    case SSeq.RNone{}:
48      Act.fail(Json.Json, B.e_not_found(), "No account found with identifier: " ++ ident)
49    case SSeq.RAmb{msg}:
50      Act.fail(Json.Json, B.e_config(), msg)
51    case SSeq.RSlot{n}:
52      direct3(ctx, seq, n, force, opts, SSeq.slot_record(seq, n))
53
54def direct1(+ctx: SBase.Ctx, +ident: String, +force: Bool, +opts: L.Opts, +m: Maybe<&2, Json.Json>) -> Act.of(Json.Json):
55  match m:
56    case None{}:
57      Act.fail(Json.Json, B.e_config(), "No accounts are managed yet")
58    case Some{seq}:
59      do ActM<Json.Json>:
60        Act.guard(SOps.valid_ident(seq, ident), B.e_validation(), "Invalid account identifier: " ++ ident)
61        direct2(ctx, seq, ident, force, opts, SSeq.resolve(seq, ident))
62
63# `cswap switch <num|email|alias> [--force]`: the result object of contract 2.3.
64# Already on the target is a no-op (`already-active`) unless `force`, which
65# rewrites the live login from the stored backup (`activated`). After a real
66# switch the live login is re-read once, `opts` later (see verify_live).
67def switch_to(+ctx: SBase.Ctx, +ident: String, +force: Bool, +opts: L.Opts) -> Act.of(Json.Json):
68  do ActM<Json.Json>:
69    m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx))
70    direct1(ctx, ident, force, opts, m)
71
72def is_disabled(+rec: Maybe<&2, Json.Json>) -> Bool:
73  match rec:
74    case None{}:
75      False{}
76    case Some{r}:
77      B.is_true(Json.get("disabled", r))
78
79def cand_of2(+slot: String, +rec: Maybe<&2, Json.Json>, +c: String, +cfg: Maybe<&2, String>, +usage: Json.Json, +models: List<&2, String>) -> P.Cand:
80  P.Cand{slot, is_disabled(rec), SBase.nonempty(c) && SBase.nonempty(B.text_or(cfg)), P.headroom(P.usage_row(usage, slot), models)}
81
82def cand_of(+root: String, +seq: Json.Json, +usage: Json.Json, +models: List<&2, String>, +slot: String) -> Act.of(P.Cand):
83  do ActM<P.Cand>:
84    c : String <- SIo.read_cred(root, slot, SIo.slot_email(seq, slot))
85    cfg : Maybe<&2, String> <- L.read_text_opt(SBase.config_path(root, slot, SIo.slot_email(seq, slot)))
86    return cand_of2(slot, SSeq.slot_record(seq, slot), c, cfg, usage, models)
87
88def cands_of(+root: String, +seq: Json.Json, +usage: Json.Json, +models: List<&2, String>, +slots: List<&2, String>) -> Act.of(List<&2, P.Cand>):
89  match slots:
90    case Nil{}:
91      Act.pure(List<&2, P.Cand>, [])
92    case Con{s, t}:
93      do ActM<List<&2, P.Cand>>:
94        c : P.Cand <- cand_of(root, seq, usage, models, s)
95        rest : List<&2, P.Cand> <- cands_of(root, seq, usage, models, t)
96        return c <> rest
97
98def drop_slot_str(+x: String, +xs: List<&2, String>) -> List<&2, String>:
99  match xs:
100    case Nil{}:
101      []
102    case Con{h, t}:
103      B.sel_ls(String.eq(h, x), drop_slot_str(x, t), h <> drop_slot_str(x, t))
104
105def usable(+cs: List<&2, P.Cand>) -> List<&2, P.Cand>:
106  match cs:
107    case Nil{}:
108      []
109    case Con{P.Cand{s, d, sw, h}, t}:
110      P.sel_lc(Bool.not(d) && sw, P.Cand{s, d, sw, h} <> usable(t), usable(t))
111
112def limits_label(+models: List<&2, String>) -> String:
113  SBase.sel.s(List.is_empty(&2, String, models), "5h/7d limit", "usage limits")
114
115def stay_msg(+cur: String) -> String:
116  "staying on Account-" ++ cur ++ "."
117
118def note_pick(+note: String, +cur: String, +curref: Json.Json, +models: List<&2, String>) -> Json.Json:
119  Store.sel.j(String.eq(note, "current-unavailable"), B.noop("best", "usage-unavailable", "Current account usage is unavailable — " ++ stay_msg(cur), curref, []),
120  Store.sel.j(String.eq(note, "no-comparison"), B.noop("best", "usage-unavailable", "No other account has usage data to compare — " ++ stay_msg(cur), curref, []),
121  Store.sel.j(String.eq(note, "incomplete-comparison"), B.noop("best", "usage-unavailable", "No account with known usage has more remaining quota; some usage is unavailable — " ++ stay_msg(cur), curref, []),
122  Store.sel.j(String.eq(note, "stay"), B.noop("best", "already-best", "Already on the account with the most remaining quota (Account-" ++ cur ++ ").", curref, []),
123    B.noop("best", "candidates-exhausted", "All accounts are at their " ++ limits_label(models) ++ " — " ++ stay_msg(cur), curref, [])))))
124
125def note_result(+note: String, +cur: String, +curref: Json.Json, +models: List<&2, String>) -> Json.Json:
126  note_pick(note, cur, curref, models)
127
128def best_out(o: P.BestOut, +ctx: SBase.Ctx, +cur: String, +curref: Json.Json, +models: List<&2, String>, +opts: L.Opts, fallthrough: Act.of(Json.Json)) -> Act.of(Json.Json):
129  match o:
130    case P.BPick{slot}:
131      do ActM<Json.Json>:
132        op : Json.Json <- R.do_switch(ctx, slot, False{}, opts)
133        return B.result_of_op(op, "best", [])
134    case P.BNote{+note}:
135      Act.cond(Json.Json, String.eq(note, "none"), fallthrough, Act.pure(Json.Json, note_result(note, cur, curref, models)))
136
137def best_pick(+usage: Json.Json, +cur: String, +models: List<&2, String>, +others: List<&2, P.Cand>) -> P.BestOut:
138  P.select_best(P.headroom(P.usage_row(usage, cur), models), usable(others))
139
140def best_run(+ctx: SBase.Ctx, +root: String, +seq: Json.Json, +cur: String, +curref: Json.Json, +usage: Json.Json, +models: List<&2, String>, +opts: L.Opts, fallthrough: Act.of(Json.Json)) -> Act.of(Json.Json):
141  do ActM<Json.Json>:
142    others : List<&2, P.Cand> <- cands_of(root, seq, usage, models, drop_slot_str(cur, B.slots_of(seq)))
143    best_out(best_pick(usage, cur, models, others), ctx, cur, curref, models, opts, fallthrough)
144
145def rotate_do(+ctx: SBase.Ctx, +slot: String, +label: String, +warns: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
146  do ActM<Json.Json>:
147    op : Json.Json <- R.do_switch(ctx, slot, False{}, opts)
148    return B.result_of_op(op, label, warns)
149
150def rotate_out(+s: P.Sel, +ctx: SBase.Ctx, +cur: String, +curref: Json.Json, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
151  match s:
152    case P.SelOk{slot, warns}:
153      Act.cond(Json.Json, String.eq(slot, cur),
154        Act.pure(Json.Json, B.noop(label, "already-active", "Already on " ++ B.ref_text(curref), curref, warns)),
155        rotate_do(ctx, slot, label, warns, opts))
156    case P.SelNone{warns, exhausted}:
157      Act.pure(Json.Json, Store.sel.j(exhausted,
158        B.noop(label, "candidates-exhausted", "All other accounts are at their " ++ limits_label(models) ++ " — " ++ stay_msg(cur), curref, warns),
159        B.noop(label, "no-valid-target", "No other accounts have valid stored credentials/config.", curref, warns)))
160
161def rotate_run2(+anchor: Maybe<&2, String>, +ctx: SBase.Ctx, +root: String, +seq: Json.Json, +cur: String, +curref: Json.Json, +usage: Json.Json, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
162  do ActM<Json.Json>:
163    cs : List<&2, P.Cand> <- cands_of(root, seq, usage, models, P.rotated(B.slots_of(seq), anchor))
164    rotate_out(P.select_next(cs, String.eq(label, "next-available")), ctx, cur, curref, label, models, opts)
165
166def rotate_run(+ctx: SBase.Ctx, +root: String, +seq: Json.Json, +cur: String, +curref: Json.Json, +usage: Json.Json, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
167  rotate_run2(B.sel_ms(String.eq(label, "next-available"), Some{cur}, B.num_str(Json.get("activeAccountNumber", seq))), ctx, root, seq, cur, curref, usage, label, models, opts)
168
169def auto4(+strategy: String, +ctx: SBase.Ctx, +root: String, +seq: Json.Json, +cur: String, +curref: Json.Json, +usage: Json.Json, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
170  Act.cond(Json.Json, String.eq(strategy, "best"),
171    best_run(ctx, root, seq, cur, curref, usage, models, opts, rotate_run(ctx, root, seq, cur, curref, usage, label, models, opts)),
172    rotate_run(ctx, root, seq, cur, curref, usage, label, models, opts))
173
174def usage_json(+u: Maybe<&2, String>) -> Json.Json:
175  Store.sel.j(B.is_some_json(L.parse_text(u)), B.unwrap_json(L.parse_text(u)), Json.JNull{})
176
177def auto3(+strategy: String, +ctx: SBase.Ctx, +seq: Json.Json, +cur: String, +email: String, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
178  do ActM<Json.Json>:
179    +u : Maybe<&2, String> <- L.read_text_opt(SBase.root_of(ctx) ++ "/cache/usage.json")
180    auto4(strategy, ctx, SBase.root_of(ctx), seq, cur, B.ref(Some{cur}, email), usage_json(u), label, models, opts)
181
182def auto2(+strategy: String, +ctx: SBase.Ctx, +seq: Json.Json, +l: L.Live, +cur: Maybe<&2, String>, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
183  match cur:
184    case None{}:
185      Act.pure(Json.Json, B.noop(label, "unmanaged-account", "Active account is not managed; run cswap --add-account", B.ref(None{}, L.lv_email(l)), []))
186    case Some{c}:
187      Act.cond(Json.Json, Nat.is_lt(List.length(&2, String, B.slots_of(seq)), 2n),
188        Act.pure(Json.Json, B.noop(label, "only-one-account", "Only one account is managed. Add more accounts to switch between.", B.ref(Some{c}, L.lv_email(l)), [])),
189        auto3(strategy, ctx, seq, c, L.lv_email(l), label, models, opts))
190
191def preferred2(+active: Maybe<&2, String>, +slots: List<&2, String>) -> Maybe<&2, String>:
192  match active:
193    case Some{a}:
194      Some{a}
195    case None{}:
196      List.head(&2, String, slots)
197
198# Switch with nobody logged in: the active slot, else the first.
199def preferred(+seq: Json.Json) -> Maybe<&2, String>:
200  preferred2(B.num_str(Json.get("activeAccountNumber", seq)), B.slots_of(seq))
201
202def reason_of(+c: P.Cand) -> String:
203  match c:
204    case P.Cand{s, d, sw, h}:
205      SBase.sel.s(d, "(disabled)", "(no stored credentials/config)")
206
207def any_switchable(+cs: List<&2, P.Cand>) -> Bool:
208  match cs:
209    case Nil{}:
210      False{}
211    case Con{P.Cand{s, d, sw, h}, t}:
212      sw || any_switchable(t)
213
214def fallback_out(+s: P.Sel, +all_cs: List<&2, P.Cand>, +ctx: SBase.Ctx, +warn: String, +label: String, +opts: L.Opts) -> Act.of(Json.Json):
215  match s:
216    case P.SelOk{slot, w}:
217      rotate_do(ctx, slot, label, [warn], opts)
218    case P.SelNone{w, e}:
219      Act.fail(Json.Json, B.e_config(), SBase.sel.s(any_switchable(all_cs), "No accounts remain in rotation. Re-enable one with: cswap enable <num|email>", "No managed accounts have valid stored credentials/config. Re-add a slot with: cswap --add-account --slot <number>"))
220
221def logged_out3(+c: P.Cand, +rest: List<&2, P.Cand>, +ctx: SBase.Ctx, +label: String, +opts: L.Opts) -> Act.of(Json.Json):
222  match c:
223    case P.Cand{slot, d, sw, h}:
224      Act.cond(Json.Json, Bool.not(d) && sw,
225        rotate_do(ctx, slot, label, [], opts),
226        fallback_out(P.select_next(rest, False{}), Con{c, rest}, ctx, "Skipped Account-" ++ slot ++ " " ++ reason_of(c), label, opts))
227
228def logged_out2(+cs: List<&2, P.Cand>, +ctx: SBase.Ctx, +label: String, +opts: L.Opts) -> Act.of(Json.Json):
229  match cs:
230    case Nil{}:
231      Act.fail(Json.Json, B.e_config(), "No accounts are managed yet")
232    case Con{c, rest}:
233      logged_out3(c, rest, ctx, label, opts)
234
235def logged_out4(+m: Maybe<&2, String>, +ctx: SBase.Ctx, +seq: Json.Json, +label: String, +opts: L.Opts) -> Act.of(Json.Json):
236  match m:
237    case None{}:
238      Act.fail(Json.Json, B.e_config(), "No accounts are managed yet")
239    case Some{t}:
240      do ActM<Json.Json>:
241        cs : List<&2, P.Cand> <- cands_of(SBase.root_of(ctx), seq, Json.JNull{}, [], Con{t, drop_slot_str(t, B.slots_of(seq))})
242        logged_out2(cs, ctx, label, opts)
243
244def logged_out(+ctx: SBase.Ctx, +seq: Json.Json, +label: String, +opts: L.Opts) -> Act.of(Json.Json):
245  logged_out4(preferred(seq), ctx, seq, label, opts)
246
247def auto_branch(+strategy: String, +ctx: SBase.Ctx, +seq: Json.Json, +l: L.Live, +label: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
248  Act.cond(Json.Json, String.is_empty(L.lv_email(l)), logged_out(ctx, seq, label, opts), auto2(strategy, ctx, seq, l, L.lv_slot(l, seq), label, models, opts))
249
250def auto1(+strategy: String, +ctx: SBase.Ctx, +label: String, +models: List<&2, String>, +opts: L.Opts, +m: Maybe<&2, Json.Json>) -> Act.of(Json.Json):
251  match m:
252    case None{}:
253      Act.fail(Json.Json, B.e_config(), "No accounts are managed yet")
254    case Some{seq}:
255      do ActM<Json.Json>:
256        +l : L.Live <- L.read_live(ctx)
257        auto_branch(strategy, ctx, seq, l, label, models, opts)
258
259# `cswap switch [--strategy best|next-available] [--model NAMES]`: strategy
260# "" rotates through `sequence`; "next-available" also skips accounts with no
261# headroom; "best" moves to the account with strictly more headroom. Usage is
262# read from cache/usage.json as it stands (no fetch). A logged-out machine
263# activates the active (else first) slot. The result is the object of
264# contract 2.3; no-op outcomes are results, not errors. A live unmanaged login
265# is reported as `unmanaged-account` (the CLI adds it with Store.add_current).
266def switch_auto(+ctx: SBase.Ctx, +strategy: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json):
267  do ActM<Json.Json>:
268    m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx))
269    auto1(strategy, ctx, B.sel_s(String.eq(strategy, "best") || String.eq(strategy, "next-available"), strategy, "rotation"), B.sel_ls(String.eq(strategy, "best") || String.eq(strategy, "next-available"), models, []), opts, m)
270
271# ---- who owns a diverged live credential ------------------------------------------------------