daemon/walk.bend

cswap-bend-cli@0.1.0.0 · 283 lines · 49 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/clock.bend as Clock
5import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
6import cswap-bend-cli@0.1.0.0/daemon/events.bend as Ev
7import cswap-bend-cli@0.1.0.0/daemon/cadence.bend as Cad
8import cswap-bend-cli@0.1.0.0/daemon/dec.bend as Dc
9import cswap-bend-cli@0.1.0.0/daemon/apply.bend as Ap
10import cswap-bend-cli@0.1.0.0/daemon/tick.bend as Tk
11import cswap-bend-cli@0.1.0.0/daemon/gather.bend as G
12import cswap-bend-cli@0.1.0.0/daemon/freshen.bend as Fr
13import cswap-bend-cli@0.1.0.0/daemon/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
23type 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.
27type Move is Data:
28  MDone{out: Tk.Out}
29  MRetry{trail: Trail}
30
31# Fixed inputs of the walk.
32type 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.
36type Verdict is Data:
37  VOk{}
38  VDead{}
39  VConflict{}
40  VSkip{}
41  VSys{}
42  VTrans{}
43
44def ActM(-A: Type) -> Type:
45  Act.of(A)
46
47def ActM.pure(-A: Type, x: A) -> Act.of(A):
48  Act.pure(A, x)
49
50def 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
53def no_trail() -> Trail:
54  Trail{[], [], False{}, None{}, ""}
55
56def trail_skip(t: Trail) -> List<&2, String>:
57  match t:
58    case Trail{s, e, tr, f, sy}:
59      s
60
61def 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.
67def 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
74def 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
79def trail_events(t: Trail) -> List<&2, Json.Json>:
80  match t:
81    case Trail{s, e, tr, f, sy}:
82      e
83
84def 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.
90def 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
95def applied(+o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Tk.Out):
96  do ActM<Tk.Out>:
97    res : Dc.TickRes <- Ap.apply(c, d)
98    return merged(o, res)
99
100def info_of(o: Cad.Outcome, +np: Maybe<&2, U32>) -> Cad.Info:
101  Cad.Info{o, None{}, False{}, False{}, np}
102
103def 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
114def sys_message(+s: String) -> String:
115  msg2(String.eq(s, "store-unmirrored"), String.eq(s, "invalid_client"), String.eq(s, "stash-unreadable"))
116
117def 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
120def 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
129def 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.
136def 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
139def 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
142def 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.
151def 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
154def 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
159def w_env(w: W) -> Tk.Env:
160  match w:
161    case W{e, t, s, m}:
162      e
163
164def w_t0(w: W) -> U32:
165  match w:
166    case W{e, t, s, m}:
167      t
168
169def w_ts(w: W) -> String:
170  match w:
171    case W{e, t, s, m}:
172      s
173
174def w_m(w: W) -> Maybe<&2, G.Snap>:
175  match w:
176    case W{e, t, s, m}:
177      m
178
179def done_with(w: W, +tr: Trail, +o: Tk.Out, c: Dc.Ctx, d: Dc.Dec) -> Act.of(Move):
180  do ActM<Move>:
181    r : Tk.Out <- applied(with_events(lead_of(tr, o), trail_events(tr)), c, d)
182    return MDone{r}
183
184def quarantined(+w: W, tr: Trail, +o: Tk.Out, +slot: String, +email: String, +reason: String) -> Act.of(Move):
185  do ActM<Move>:
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
189def verdict2(+is_sys: Bool, +st: String) -> Verdict:
190  match is_sys:
191    case True{}:
192      VSys{}
193    case False{}:
194      VTrans{}
195
196def 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
209def 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
212def 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
227def 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<Move>:
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
232def 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
235def 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
238def 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
245def 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.
253def 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
256def 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
263def 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
270def again(w: W, a: Act.of(Move)) -> Act.of(Move):
271  Act.bind(Move, Move, a, mv => retry(w, mv))
272
273def again4(+w: W, a: Act.of(Move)) -> Act.of(Move):
274  again(w, again(w, again(w, again(w, a))))
275
276def 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.
282def run(+env: Tk.Env, +t0: U32, +ts: String, +m: Maybe<&2, G.Snap>) -> Act.of(Tk.Out):
283  walk(W{env, t0, ts, m})