1 import Base 2 import ./json.bend as Json 3 import ./act.bend as Act 4 import ./store.bend as Store 5 import ./swbase.bend as B 6 import ./swpick.bend as P 7 import ./swlive.bend as L 8 import ./swrun.bend as R 9 import ./store/base.bend as SBase 10 import ./store/io.bend as SIo 11 import ./store/ops.bend as SOps 12 import ./store/seq.bend as SSeq 13 14 # Switch, part 6: the entry points (switch_to, switch_auto). 15 16 # `do ActM:` (see store.bend). 17 18 def ActM(-A: Type) -> Type: 19 Act.of(A) 20 21 def ActM.pure(-A: Type, x: A) -> Act.of(A): 22 Act.pure(A, x) 23 24 def 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 27 def 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: 33 op : Json.Json <- R.do_switch(ctx, n, force, opts) 34 return B.forced_result(force, B.result_of_op(op, "direct", [])) 35 36 def 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: 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 45 def 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 54 def 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: 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 [--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). 67 def switch_to(+ctx: SBase.Ctx, +ident: String, +force: Bool, +opts: L.Opts) -> Act.of(Json.Json): 68 do ActM: 69 m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx)) 70 direct1(ctx, ident, force, opts, m) 71 72 def 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 79 def 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 82 def cand_of(+root: String, +seq: Json.Json, +usage: Json.Json, +models: List<&2, String>, +slot: String) -> Act.of(P.Cand): 83 do ActM: 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 88 def 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>: 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 98 def 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 105 def 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 112 def limits_label(+models: List<&2, String>) -> String: 113 SBase.sel.s(List.is_empty(&2, String, models), "5h/7d limit", "usage limits") 114 115 def stay_msg(+cur: String) -> String: 116 "staying on Account-" ++ cur ++ "." 117 118 def 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 125 def note_result(+note: String, +cur: String, +curref: Json.Json, +models: List<&2, String>) -> Json.Json: 126 note_pick(note, cur, curref, models) 127 128 def 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: 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 137 def 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 140 def 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: 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 145 def rotate_do(+ctx: SBase.Ctx, +slot: String, +label: String, +warns: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json): 146 do ActM: 147 op : Json.Json <- R.do_switch(ctx, slot, False{}, opts) 148 return B.result_of_op(op, label, warns) 149 150 def 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 161 def 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: 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 166 def 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 169 def 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 174 def 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 177 def 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: 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 182 def 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 191 def 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. 199 def preferred(+seq: Json.Json) -> Maybe<&2, String>: 200 preferred2(B.num_str(Json.get("activeAccountNumber", seq)), B.slots_of(seq)) 201 202 def 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 207 def 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 214 def 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 ", "No managed accounts have valid stored credentials/config. Re-add a slot with: cswap --add-account --slot ")) 220 221 def 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 228 def 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 235 def 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: 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 244 def 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 247 def 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 250 def 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: 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). 266 def switch_auto(+ctx: SBase.Ctx, +strategy: String, +models: List<&2, String>, +opts: L.Opts) -> Act.of(Json.Json): 267 do ActM: 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 ------------------------------------------------------