1 import Base 2 import ../json.bend as Json 3 import ../act.bend as Act 4 import ../clock.bend as Clock 5 import ../store/base.bend as SBase 6 import ./events.bend as Ev 7 import ./cadence.bend as Cad 8 import ./dec.bend as Dc 9 import ./apply.bend as Ap 10 import ./tick.bend as Tk 11 import ./gather.bend as G 12 import ./freshen.bend as Fr 13 import ./quarantine.bend as Q 14 15 # Walking the ranked targets (autoswitch.py's `for num in ordered`): the 16 # policy names the best one; its token is freshened first; a dead credential is 17 # quarantined and the policy asked again without it; a transient failure moves 18 # on too. Nothing viable is `no-viable-target` (or an error when the network was 19 # the reason). 20 21 # What the walk has met so far. 22 23 type Trail is Data: 24 Trail{skip: List<&2, String>, events: List<&2, Json.Json>, transient: Bool, first: Maybe<&2, Tk.Out>, sys: String} 25 26 # What one attempt came to: finished, or retry with this trail. 27 type Move is Data: 28 MDone{out: Tk.Out} 29 MRetry{trail: Trail} 30 31 # Fixed inputs of the walk. 32 type W is Data: 33 W{env: Tk.Env, t0: U32, ts: String, m: Maybe<&2, G.Snap>} 34 35 # What freshening a candidate came to. 36 type Verdict is Data: 37 VOk{} 38 VDead{} 39 VConflict{} 40 VSkip{} 41 VSys{} 42 VTrans{} 43 44 def ActM(-A: Type) -> Type: 45 Act.of(A) 46 47 def ActM.pure(-A: Type, x: A) -> Act.of(A): 48 Act.pure(A, x) 49 50 def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B): 51 Act.bind(A, B, m, f) 52 53 def no_trail() -> Trail: 54 Trail{[], [], False{}, None{}, ""} 55 56 def trail_skip(t: Trail) -> List<&2, String>: 57 match t: 58 case Trail{s, e, tr, f, sy}: 59 s 60 61 def trail_transient(t: Trail) -> Bool: 62 match t: 63 case Trail{s, e, tr, f, sy}: 64 tr 65 66 # The first decision's Out (its poll events), else the one at hand. 67 def lead_of(t: Trail, +o: Tk.Out) -> Tk.Out: 68 match t: 69 case Trail{s, e, tr, Some{f}, sy}: 70 f 71 case _: 72 o 73 74 def with_events(+o: Tk.Out, +extra: List<&2, Json.Json>) -> Tk.Out: 75 match o: 76 case Tk.Out{Dc.TickRes{i, evs}, m, a, es, ac, ck}: 77 Tk.Out{Dc.TickRes{i, List.append(&2, Json.Json, evs, extra)}, m, a, es, ac, ck} 78 79 def trail_events(t: Trail) -> List<&2, Json.Json>: 80 match t: 81 case Trail{s, e, tr, f, sy}: 82 e 83 84 def with_info(+o: Tk.Out, i: Cad.Info) -> Tk.Out: 85 match o: 86 case Tk.Out{Dc.TickRes{i0, evs}, m, a, es, ac, ck}: 87 Tk.Out{Dc.TickRes{i, evs}, m, a, es, ac, ck} 88 89 # The poll events first, then what acting on the decision produced. 90 def merged(+o: Tk.Out, res: Dc.TickRes) -> Tk.Out: 91 match o res: 92 case Tk.Out{Dc.TickRes{i0, lead}, m, a, es, ac, ck} Dc.TickRes{info, evs}: 93 Tk.Out{Dc.TickRes{info, List.append(&2, Json.Json, lead, evs)}, m, a, es, ac, ck} 94 95 def applied(+o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Tk.Out): 96 do ActM: 97 res : Dc.TickRes <- Ap.apply(c, d) 98 return merged(o, res) 99 100 def info_of(o: Cad.Outcome, +np: Maybe<&2, U32>) -> Cad.Info: 101 Cad.Info{o, None{}, False{}, False{}, np} 102 103 def msg2(+a: Bool, +b: Bool, +c: Bool) -> String: 104 match a b c: 105 case True{} _ _: 106 "CLAUDE_SECURESTORAGE_CONFIG_DIR is set — unset it or run cswap from a normal shell" 107 case False{} True{} _: 108 "cswap's OAuth client was rejected — systemic, not this account" 109 case False{} False{} True{}: 110 "a stashed successor is unreadable — unlock the keychain or fix the file, then retry; `cswap unclaimed` inspects it" 111 case False{} False{} False{}: 112 "another cswap surface holds the slot — retries next pass" 113 114 def sys_message(+s: String) -> String: 115 msg2(String.eq(s, "store-unmirrored"), String.eq(s, "invalid_client"), String.eq(s, "stash-unreadable")) 116 117 def failed_with(+o: Tk.Out, +ts: String, +msg: String) -> Tk.Out: 118 with_info(with_events(o, [Ev.error(ts, msg, True{})]), info_of(Cad.Failed{}, None{})) 119 120 def gave_up3(+nothing_systemic: Bool, +transient: Bool, +sy: String, +o: Tk.Out, +ts: String) -> Tk.Out: 121 match nothing_systemic transient: 122 case False{} _: 123 failed_with(o, ts, "could not freshen: " ++ sys_message(sy)) 124 case True{} True{}: 125 failed_with(o, ts, "could not freshen any candidate (network?)") 126 case True{} False{}: 127 with_info(with_events(o, [Ev.no_switch(ts, "no-viable-target", "")]), info_of(Cad.Blocked{}, None{})) 128 129 def trail_sys(+t: Trail) -> String: 130 match t: 131 case Trail{s, e, tr, f, sy}: 132 sy 133 134 # Every candidate failed to freshen: a systemic refusal, or the network, is an 135 # error; otherwise there was simply nothing viable. 136 def gave_up(+t: Trail, +o: Tk.Out, +ts: String) -> Tk.Out: 137 gave_up3(String.is_empty(trail_sys(t)), trail_transient(t), trail_sys(t), with_events(lead_of(t, o), trail_events(t)), ts) 138 139 def extend(+t: Trail, +slot: String, +more: List<&2, Json.Json>, +transient: Bool, +o: Tk.Out) -> Trail: 140 Trail{slot <> trail_skip(t), List.append(&2, Json.Json, trail_events(t), more), trail_transient(t) || transient, Some{lead_of(t, o)}, trail_sys(t)} 141 142 def worse2(+take_new: Bool, +old: String, +new: String) -> String: 143 match take_new: 144 case True{}: 145 new 146 case False{}: 147 old 148 149 # The most actionable systemic cause wins (a lower rank), so one needing a human 150 # never hides behind one that clears itself. 151 def worse(+old: String, +new: String) -> String: 152 worse2(String.is_empty(old) || U32.is_lt(Fr.systemic_rank(new), Fr.systemic_rank(old)), old, new) 153 154 def extend_sys(+t: Trail, +slot: String, +status: String, +o: Tk.Out) -> Trail: 155 Trail{slot <> trail_skip(t), trail_events(t), trail_transient(t), Some{lead_of(t, o)}, worse(trail_sys(t), status)} 156 157 # ---- the walk --------------------------------------------------------------------------------------------- 158 159 def w_env(w: W) -> Tk.Env: 160 match w: 161 case W{e, t, s, m}: 162 e 163 164 def w_t0(w: W) -> U32: 165 match w: 166 case W{e, t, s, m}: 167 t 168 169 def w_ts(w: W) -> String: 170 match w: 171 case W{e, t, s, m}: 172 s 173 174 def w_m(w: W) -> Maybe<&2, G.Snap>: 175 match w: 176 case W{e, t, s, m}: 177 m 178 179 def done_with(w: W, +tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Move): 180 do ActM: 181 r : Tk.Out <- applied(with_events(lead_of(tr, o), trail_events(tr)), c, d) 182 return MDone{r} 183 184 def quarantined(+w: W, tr: Trail, +o: Tk.Out, +slot: String, +email: String, +reason: String) -> Act.of(Move): 185 do ActM: 186 q : Json.Json <- Q.quarantine(SBase.root_of(Tk.env_ctx(w_env(w))), slot, email, reason, w_ts(w)) 187 return MRetry{extend(tr, slot, [q], False{}, o)} 188 189 def verdict2(+is_sys: Bool, +st: String) -> Verdict: 190 match is_sys: 191 case True{}: 192 VSys{} 193 case False{}: 194 VTrans{} 195 196 def verdict1(+ok: Bool, +dead: Bool, +conflict: Bool, +skip: Bool, +st: String) -> Verdict: 197 match ok dead conflict skip: 198 case True{} _ _ _: 199 VOk{} 200 case False{} True{} _ _: 201 VDead{} 202 case False{} False{} True{} _: 203 VConflict{} 204 case False{} False{} False{} True{}: 205 VSkip{} 206 case _ _ _ _: 207 verdict2(U32.is_lt(0, Fr.systemic_rank(st)), st) 208 209 def verdict(+st: String) -> Verdict: 210 verdict1(String.eq(st, "ok"), String.eq(st, "invalid_grant"), String.eq(st, "identity-conflict"), String.eq(st, "skip-live-session"), st) 211 212 def route(+v: Verdict, +st: String, w: W, tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec, +slot: String, +email: String) -> Act.of(Move): 213 match v: 214 case VOk{}: 215 done_with(w, tr, o, c, d) 216 case VDead{}: 217 quarantined(w, tr, o, slot, email, "invalid_grant") 218 case VConflict{}: 219 quarantined(w, tr, o, slot, email, "identity-conflict") 220 case VSkip{}: 221 Act.pure(Move, MRetry{extend(tr, slot, [], False{}, o)}) 222 case VSys{}: 223 Act.pure(Move, MRetry{extend_sys(tr, slot, st, o)}) 224 case VTrans{}: 225 Act.pure(Move, MRetry{extend(tr, slot, [], True{}, o)}) 226 227 def try_target(+w: W, tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec, +slot: String, +email: String) -> Act.of(Move): 228 do ActM: 229 +st : String <- Fr.status(SBase.root_of(Tk.env_ctx(w_env(w))), slot, email, w_t0(w)) 230 route(verdict(st), st, w, tr, o, c, d, slot, email) 231 232 def on_go(+w: W, +tr: Trail, +o: Tk.Out, +c: Dc.Ctx, +d: Dc.Dec, +slot: String, +email: String) -> Act.of(Move): 233 Act.cond(Move, Tk.env_dry(w_env(w)), done_with(w, tr, o, c, d), try_target(w, tr, o, c, d, slot, email)) 234 235 def on_other(+w: W, +tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Move): 236 Act.cond(Move, List.is_empty(&2, String, trail_skip(tr)), done_with(w, tr, o, c, d), Act.pure(Move, MDone{gave_up(tr, o, w_ts(w))})) 237 238 def on_act(w: W, tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Move): 239 match d: 240 case Dc.DGo{+to, +email, trig, lh, lr}: 241 on_go(w, tr, o, c, d, to, email) 242 case _: 243 on_other(w, tr, o, c, d) 244 245 def next(w: W, tr: Trail, +nx: Tk.Next) -> Act.of(Move): 246 match nx: 247 case Tk.NDone{o}: 248 Act.pure(Move, MDone{lead_of(tr, o)}) 249 case Tk.NAct{o, c, d}: 250 on_act(w, tr, o, c, d) 251 252 # Decide again (without the slots already ruled out) and act. 253 def attempt(+w: W, +tr: Trail) -> Act.of(Move): 254 next(w, tr, Tk.tick(Tk.with_skip(w_env(w), trail_skip(tr)), w_t0(w), w_m(w))) 255 256 def retry(w: W, mv: Move) -> Act.of(Move): 257 match mv: 258 case MDone{o}: 259 Act.pure(Move, MDone{o}) 260 case MRetry{tr}: 261 attempt(w, tr) 262 263 def settle(+w: W, mv: Move) -> Tk.Out: 264 match mv: 265 case MDone{o}: 266 o 267 case MRetry{tr}: 268 gave_up(tr, Tk.idle_out(w_env(w), w_ts(w), False{}), w_ts(w)) 269 270 def again(w: W, a: Act.of(Move)) -> Act.of(Move): 271 Act.bind(Move, Move, a, mv => retry(w, mv)) 272 273 def again4(+w: W, a: Act.of(Move)) -> Act.of(Move): 274 again(w, again(w, again(w, again(w, a)))) 275 276 def walk(+w: W) -> Act.of(Tk.Out): 277 Act.bind(Move, Tk.Out, again4(w, again4(w, again4(w, again4(w, attempt(w, no_trail()))))), mv => Act.pure(Tk.Out, settle(w, mv))) 278 279 # Up to 16 candidates are tried per tick (autoswitch.py walks the whole ranking; 280 # Bend has no mutual recursion for an unbounded loop here). A longer ranking is 281 # finished on the next tick: the ones found dead are quarantined by then. 282 def run(+env: Tk.Env, +t0: U32, +ts: String, +m: Maybe<&2, G.Snap>) -> Act.of(Tk.Out): 283 walk(W{env, t0, ts, m})