daemon/events.bend

cswap-bend-cli@0.1.0.0 · 248 lines · 46 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3
4# The JSONL events of `cswap auto --json` (contract 4.5, autoswitch.py
5# *Event classes). Percentages are centi-percent U32 (85.5 % = 8550); they
6# print the way Python prints the float: `60.0`, `42.5`, `87.66`.
7
8type Win is Data:
9  Win{name: String, pct: U32}
10
11# One account in a poll: headroom (None = unknown), the relevant windows, and
12# the fetch error to show when its usage is unknown.
13type Row is Data:
14  Row{slot: String, headroom: Maybe<&2, U32>, windows: List<&2, Win>, error: Maybe<&2, String>}
15
16def sel(+c: Bool, +x: String, +y: String) -> String:
17  match c:
18    case True{}:
19      x
20    case False{}:
21      y
22
23def two(+n: U32) -> String:
24  sel(U32.is_lt(n, 10), "0" ++ U32.show(n), U32.show(n))
25
26# Digits after the point with a trailing zero dropped, at least one digit.
27def frac(+cents: U32) -> String:
28  sel(U32.is_eq((cents % 10 : U32), 0), U32.show((cents / 10 : U32)), two(cents))
29
30# 6000 -> "60.0", 4250 -> "42.5", 8766 -> "87.66".
31def float_text(+c: U32) -> String:
32  U32.show((c / 100 : U32)) ++ "." ++ frac((c % 100 : U32))
33
34def fl(+c: U32) -> Json.Json:
35  Json.JNum{float_text(c)}
36
37# `%.10g`: 4000 -> "40", 8766 -> "87.66".
38def pct_label(+c: U32) -> String:
39  sel(U32.is_eq((c % 100 : U32), 0), U32.show((c / 100 : U32)), float_text(c))
40
41def pair(+k: String, +v: Json.Json, +rest: Json.Json) -> Json.Json:
42  Json.JField{k, v, rest}
43
44def str(+s: String) -> Json.Json:
45  Json.JStr{s}
46
47# {"schemaVersion": 1, "event": KIND, "ts": TS, ...fields}
48def event(+kind: String, +ts: String, +fields: Json.Json) -> Json.Json:
49  Json.JObj{pair("schemaVersion", Json.JNum{"1"}, pair("event", str(kind), pair("ts", str(ts), fields)))}
50
51# {"number": int, "email": str}
52def ref(+slot: String, +email: String) -> Json.Json:
53  Json.JObj{pair("number", Json.JNum{slot}, pair("email", str(email), Json.JNil{}))}
54
55def ref_opt(+m: Maybe<&2, String>, +email: String) -> Json.Json:
56  match m:
57    case None{}:
58      Json.JNull{}
59    case Some{s}:
60      ref(s, email)
61
62# ---- poll ---------------------------------------------------------------------------------
63
64def hr_json(+m: Maybe<&2, U32>) -> Json.Json:
65  match m:
66    case None{}:
67      Json.JNull{}
68    case Some{c}:
69      fl(c)
70
71def headroom_chain(+rows: List<&2, Row>) -> Json.Json:
72  match rows:
73    case Nil{}:
74      Json.JNil{}
75    case Con{Row{s, h, w, e}, t}:
76      pair(s, hr_json(h), headroom_chain(t))
77
78def windows_chain(+ws: List<&2, Win>) -> Json.Json:
79  match ws:
80    case Nil{}:
81      Json.JNil{}
82    case Con{Win{n, p}, t}:
83      pair(n, fl(p), windows_chain(t))
84
85def windows_pick(+empty: Bool, +s: String, +w: List<&2, Win>, +rest: Json.Json) -> Json.Json:
86  match empty:
87    case True{}:
88      rest
89    case False{}:
90      pair(s, Json.JObj{windows_chain(w)}, rest)
91
92# {slot: {"5h": 20.0, ...}} for the accounts that have windows.
93def windows_all(+rows: List<&2, Row>) -> Json.Json:
94  match rows:
95    case Nil{}:
96      Json.JNil{}
97    case Con{Row{s, h, w, e}, t}:
98      windows_pick(List.is_empty(&2, Win, w), s, w, windows_all(t))
99
100# Only an unknown-usage account with an error is listed.
101def errors_pick(+h: Maybe<&2, U32>, +e: Maybe<&2, String>, +s: String, +rest: Json.Json) -> Json.Json:
102  match h e:
103    case None{} Some{m}:
104      pair(s, str(m), rest)
105    case _ _:
106      rest
107
108def errors_all(+rows: List<&2, Row>) -> Json.Json:
109  match rows:
110    case Nil{}:
111      Json.JNil{}
112    case Con{Row{s, h, w, e}, t}:
113      errors_pick(h, e, s, errors_all(t))
114
115def opt_obj(+c: Json.Json, +key: String, +rest: Json.Json) -> Json.Json:
116  match c:
117    case Json.JNil{}:
118      rest
119    case _:
120      pair(key, Json.JObj{c}, rest)
121
122# fetchErrors and windowsPct appear only when non-empty.
123def poll(+ts: String, +active: Json.Json, +rows: List<&2, Row>, +threshold: U32) -> Json.Json:
124  event("poll", ts, pair("active", active, pair("headroomPct", Json.JObj{headroom_chain(rows)}, pair("threshold", fl(threshold),
125    opt_obj(errors_all(rows), "fetchErrors", opt_obj(windows_all(rows), "windowsPct", Json.JNil{}))))))
126
127# ---- the rest ----------------------------------------------------------------------------
128
129def strs(+xs: List<&2, String>) -> List<&2, Json.Json>:
130  match xs:
131    case Nil{}:
132      []
133    case Con{h, t}:
134      str(h) <> strs(t)
135
136def switch(+ts: String, +trigger: String, +from: Json.Json, +to: Json.Json, +warnings: List<&2, String>, +dry_run: Bool) -> Json.Json:
137  event("switch", ts, pair("trigger", str(trigger), pair("from", from, pair("to", to,
138    pair("warnings", Json.arr(strs(warnings)), pair("dryRun", Json.JBool{dry_run}, Json.JNil{}))))))
139
140def no_switch(+ts: String, +reason: String, +detail: String) -> Json.Json:
141  event("no-switch", ts, pair("reason", str(reason), pair("detail", str(detail), Json.JNil{})))
142
143def quarantined(+ts: String, +slot: String, +email: String, +reason: String) -> Json.Json:
144  event("account-quarantined", ts, pair("number", str(slot), pair("email", str(email), pair("reason", str(reason), Json.JNil{}))))
145
146def unquarantined(+ts: String, +slot: String, +email: String, +reason: String) -> Json.Json:
147  event("account-unquarantined", ts, pair("number", str(slot), pair("email", str(email), pair("reason", str(reason), Json.JNil{}))))
148
149def opt_str(+m: Maybe<&2, String>) -> Json.Json:
150  match m:
151    case None{}:
152      Json.JNull{}
153    case Some{x}:
154      str(x)
155
156def is_none(+m: Maybe<&2, String>) -> Bool:
157  match m:
158    case None{}:
159      True{}
160    case Some{x}:
161      False{}
162
163# `earliest` is an ISO Z stamp, or none when no reset is known.
164def all_exhausted(+ts: String, +earliest: Maybe<&2, String>) -> Json.Json:
165  event("all-exhausted", ts, pair("earliestResetAt", opt_str(earliest), Json.JNil{}))
166
167# `seconds` is deciseconds (round(seconds, 1)).
168def sleep(+ts: String, +deci: U32, +until: String) -> Json.Json:
169  event("sleep", ts, pair("seconds", Json.JNum{U32.show((deci / 10 : U32)) ++ "." ++ U32.show((deci % 10 : U32))}, pair("until", str(until), Json.JNil{})))
170
171def error(+ts: String, +message: String, +transient: Bool) -> Json.Json:
172  event("error", ts, pair("message", str(message), pair("transient", Json.JBool{transient}, Json.JNil{})))
173
174def config_warning(+ts: String, +message: String) -> Json.Json:
175  event("config-warning", ts, pair("message", str(message), Json.JNil{}))
176
177# One line of stdout: json.dumps(event).
178def line(+e: Json.Json) -> String:
179  Json.emit_py(e)
180
181# ---- the same events for a person (no --json) -------------------------------------------------
182
183def s_of(+m: Maybe<&2, Json.Json>) -> String:
184  match m:
185    case Some{Json.JStr{s}}:
186      s
187    case Some{Json.JNum{t}}:
188      t
189    case _:
190      ""
191
192def f_of(+j: Json.Json, +k: String) -> String:
193  s_of(Json.get(k, j))
194
195def ref_text(+j: Json.Json) -> String:
196  match j:
197    case Json.JNull{}:
198      "(none)"
199    case _:
200      "Account-" ++ f_of(j, "number") ++ " (" ++ f_of(j, "email") ++ ")"
201
202def null_or(+m: Maybe<&2, Json.Json>) -> Json.Json:
203  match m:
204    case Some{v}:
205      v
206    case None{}:
207      Json.JNull{}
208
209def get_or_null(+j: Json.Json, +k: String) -> Json.Json:
210  null_or(Json.get(k, j))
211
212def poll_text2(+active: Json.Json, +e: Json.Json) -> String:
213  match active:
214    case Json.JNull{}:
215      "poll: no active account"
216    case _:
217      "poll: " ++ ref_text(active) ++ " (switch at " ++ f_of(e, "threshold") ++ "%)"
218
219def poll_text(+e: Json.Json) -> String:
220  poll_text2(get_or_null(e, "active"), e)
221
222def detail_text(+d: String) -> String:
223  sel(String.is_empty(d), "", " (" ++ d ++ ")")
224
225def is_dry(+m: Maybe<&2, Json.Json>) -> Bool:
226  match m:
227    case Some{Json.JBool{b}}:
228      b
229    case _:
230      False{}
231
232def switch_text(+e: Json.Json) -> String:
233  sel(is_dry(Json.get("dryRun", e)), "[dry-run] would switch ", "Switched ") ++ ref_text(get_or_null(e, "from")) ++ " -> " ++ ref_text(get_or_null(e, "to")) ++ " (" ++ f_of(e, "trigger") ++ ")"
234
235def human2(+kind: String, +e: Json.Json) -> String:
236  sel(String.eq(kind, "poll"), poll_text(e),
237  sel(String.eq(kind, "switch"), switch_text(e),
238  sel(String.eq(kind, "no-switch"), "no switch: " ++ f_of(e, "reason") ++ detail_text(f_of(e, "detail")),
239  sel(String.eq(kind, "all-exhausted"), "all accounts exhausted; earliest reset " ++ f_of(e, "earliestResetAt"),
240  sel(String.eq(kind, "account-quarantined"), "Account-" ++ f_of(e, "number") ++ " (" ++ f_of(e, "email") ++ ") quarantined: " ++ f_of(e, "reason"),
241  sel(String.eq(kind, "account-unquarantined"), "Account-" ++ f_of(e, "number") ++ " (" ++ f_of(e, "email") ++ ") back in rotation (" ++ f_of(e, "reason") ++ ")",
242  sel(String.eq(kind, "sleep"), "sleeping " ++ f_of(e, "seconds") ++ "s (until " ++ f_of(e, "until") ++ ")",
243  sel(String.eq(kind, "error"), "error: " ++ f_of(e, "message") ++ " (will retry)",
244  "warning: " ++ f_of(e, "message")))))))))
245
246# One line for the event, in the reference's words where they are simple.
247def human(+e: Json.Json) -> String:
248  human2(f_of(e, "event"), e)