collect.bend

cswap-bend-cli@0.1.0.0 · 301 lines · 55 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/urec.bend as Urec
3import cswap-bend-cli@0.1.0.0/pollrow.bend as Pollrow
4import cswap-bend-cli@0.1.0.0/usage/cred.bend as UCred
5import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
6import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
7import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
8import cswap-bend-cli@0.1.0.0/json.bend as Json
9import cswap-bend-cli@0.1.0.0/clock.bend as Clock
10import cswap-bend-cli@0.1.0.0/fs.bend as Fs
11import cswap-bend-cli@0.1.0.0/act.bend as Act
12import cswap-bend-cli@0.1.0.0/poll.bend as Poll
13import cswap-bend-cli@0.1.0.0/ustore.bend as Ustore
14import cswap-bend-cli@0.1.0.0/usage.bend as Usage
15import cswap-bend-cli@0.1.0.0/view.bend as View
16import cswap-bend-cli@0.1.0.0/store.bend as Store
17
18# API (also the daemon's collector; do not copy it):
19#   gather(ctx, seq) -> Act(List<Info>)      every account in `sequence`, the active one with the live credential
20#   collect(ctx, infos, now, models, threshold_tenths, tick) -> Act(List<View.Acct>)
21#       one usage pass: lease what is eligible, fetch it (gated refresh for inactive accounts), merge, read back
22#   fingerprint(creds) -> IO(String)         the usage.json fingerprint (sha256 via shasum, token on stdin)
23# `models` are the scoped-window names that gate an account; `threshold_tenths` is the autoswitch threshold in tenths of a percent.
24#
25# Store-backed usage collection for list and status (_collect_usage_entries):
26# read the rows, lease what is eligible, fetch it, merge the outcomes, and
27# hand back one View.Acct per account. Fetches run one after another (the
28# reference runs them in parallel with a 0.25 s stagger).
29
30def ActM(-A: Type) -> Type:
31  Act.of(A)
32
33def ActM.pure(-A: Type, x: A) -> Act.of(A):
34  Act.pure(A, x)
35
36def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
37  Act.bind(A, B, m, f)
38
39# One managed account with the credential its usage is read with: the live
40# one for the active slot, the stored backup otherwise.
41type Info is Data:
42  Info{num: U32, email: String, org_name: String, org_uuid: String, alias: String, disabled: Bool,
43       active: Bool, creds: String}
44
45def id_of(+i: Info) -> Ustore.Id:
46  match i:
47    case Info{num, email, org_name, org_uuid, alias, disabled, active, creds}:
48      Ustore.Id{U32.show(num), email, org_uuid}
49
50def slot_of(+i: Info) -> String:
51  match i:
52    case Info{num, email, org_name, org_uuid, alias, disabled, active, creds}:
53      U32.show(num)
54
55def read_text2(r: Fs.Res(String)) -> String:
56  match r:
57    case Fail{e}:
58      ""
59    case Done{t}:
60      t
61
62# A file's text, "" when it cannot be read.
63def read_text(+path: String) -> IO(String):
64  IO.bind(Fs.Res(String), String, Fs.read_file(path), r => IO.pure(String, read_text2(r)))
65
66def live_ident(+ctx: SBase.Ctx) -> IO(Store.Ident):
67  IO.bind(String, Store.Ident, read_text(SBase.global_config_path(ctx)), t => IO.pure(Store.Ident, Store.ident_of(Json.unwrap(Json.parse(t)))))
68
69def active.pick(ok: Bool, +seq: Json.Json, +id: Store.Ident) -> Maybe<&2, String>:
70  match ok:
71    case True{}:
72      SSeq.find_slot(seq, Store.id_email(id), Store.id_org(id))
73    case False{}:
74      None{}
75
76def active_slot(+seq: Json.Json, +id: Store.Ident) -> Maybe<&2, String>:
77  active.pick(SBase.not_(String.is_empty(Store.id_email(id))), seq, id)
78
79def is_active(m: Maybe<&2, String>, +slot: String) -> Bool:
80  match m:
81    case Some{s}:
82      String.eq(s, slot)
83    case None{}:
84      False{}
85
86def jstr_of(+rec: Json.Json, +k: String) -> String:
87  SSeq.field_str(rec, k)
88
89def flag.of(m: Maybe<&2, Json.Json>) -> Bool:
90  match m:
91    case Some{Json.JBool{True{}}}:
92      True{}
93    case _:
94      False{}
95
96def rec_flag(+rec: Json.Json, +k: String) -> Bool:
97  flag.of(Json.get(k, rec))
98
99def info.mk(+n: U32, +rec: Json.Json, active: Bool, +creds: String) -> Info:
100  Info{n, jstr_of(rec, "email"), jstr_of(rec, "organizationName"), jstr_of(rec, "organizationUuid"), jstr_of(rec, "alias"), rec_flag(rec, "disabled"), active, creds}
101
102def info.creds(active: Bool, +live: String, +root: String, +slot: String, +email: String) -> Act.of(String):
103  match active:
104    case True{}:
105      Act.pure(String, live)
106    case False{}:
107      SIo.read_cred(root, slot, email)
108
109def info.rec(m: Maybe<&2, Json.Json>, +n: U32, +active: Bool, +live: String, +root: String) -> Act.of(Info):
110  match m:
111    case None{}:
112      Act.fail(Info, 1, "Account-" ++ U32.show(n) ++ " has no record")
113    case Some{+rec}:
114      Act.bind(String, Info, info.creds(active, live, root, U32.show(n), jstr_of(rec, "email")), c => Act.pure(Info, info.mk(n, rec, active, c)))
115
116def info.one(+seq: Json.Json, +n: U32, am: Maybe<&2, String>, +live: String, +root: String) -> Act.of(Info):
117  info.rec(SSeq.slot_record(seq, U32.show(n)), n, is_active(am, U32.show(n)), live, root)
118
119def infos.go(ns: List<&2, U32>, +seq: Json.Json, +am: Maybe<&2, String>, +live: String, +root: String) -> Act.of(List<&2, Info>):
120  match ns:
121    case Nil{}:
122      Act.pure(List<&2, Info>, [])
123    case Con{n, t}:
124      Act.bind(Info, List<&2, Info>, info.one(seq, n, am, live, root),
125        i => Act.bind(List<&2, Info>, List<&2, Info>, infos.go(t, seq, am, live, root), rest => Act.pure(List<&2, Info>, i <> rest)))
126
127def gather.go(+ctx: SBase.Ctx, +seq: Json.Json, +id: Store.Ident, +live: String) -> Act.of(List<&2, Info>):
128  infos.go(SSeq.seq_nums(seq), seq, active_slot(seq, id), live, SBase.root_of(ctx))
129
130# Every account in `sequence`, with the active one (by the live
131# ~/.claude.json identity) carrying the live credential.
132def gather(+ctx: SBase.Ctx, +seq: Json.Json) -> Act.of(List<&2, Info>):
133  Act.bind(Store.Ident, List<&2, Info>, Act.lift(Store.Ident, live_ident(ctx)),
134    id => Act.bind(String, List<&2, Info>, Act.lift(String, read_text(SBase.live_creds_path(ctx))), live => gather.go(ctx, seq, id, live)))
135
136def sha.of2(oe: String & String) -> String:
137  (o, e) = oe
138  String.take(o, 64n)
139
140def sha.of1(r: U32 & (String & String)) -> String:
141  (st, oe) = r
142  sha.of2(oe)
143
144def sha.of(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> String:
145  match r:
146    case Fail{e}:
147      ""
148    case Done{v}:
149      sha.of1(v)
150
151# Hex sha256 of a text through shasum(1), the text on stdin (never on argv).
152def sha256(+text: String) -> IO(String):
153  IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, String, Process.run("shasum", ["-a", "256"], text, 4096, 5000), r => IO.pure(String, sha.of(r)))
154
155def fp.rt(+tag: String, +text: String) -> IO(String):
156  IO.bind(String, String, sha256(text), h => IO.pure(String, tag ++ h))
157
158def fp.pick(m: Maybe<&2, String>, +creds: String) -> IO(String):
159  match m:
160    case Some{rt}:
161      fp.rt("sha256:", rt)
162    case None{}:
163      fp.rt("sha256-full:", creds)
164
165# oauth.credential_fingerprint: the refresh token's hash, else the whole
166# credential's.
167def fingerprint(+creds: String) -> IO(String):
168  fp.pick(UCred.refresh_token(UCred.oauth.of(creds)), creds)
169
170# 0 none, 1 dead refresh-token lineage, 2 a strike the credential has outgrown.
171def dead.cmp(+stored: String, struck: Maybe<&2, String>) -> U32:
172  match struck:
173    case None{}:
174      1
175    case Some{s}:
176      Clock.sel(String.eq(s, stored), 1, 2)
177
178def dead.go(has: Bool, struck: Maybe<&2, String>, +creds: String) -> IO(U32):
179  match has:
180    case False{}:
181      IO.pure(U32, 0)
182    case True{}:
183      IO.bind(String, U32, fingerprint(creds), f => IO.pure(U32, dead.cmp(f, struck)))
184
185def dead.fp(strikes: U32, struck: Maybe<&2, String>, +creds: String) -> IO(U32):
186  dead.go(U32.is_ge(strikes, 1), struck, creds)
187
188def dead.row(+r: Pollrow.Row, +creds: String) -> IO(U32):
189  match r:
190    case Pollrow.Row{fetched, lg, la, fl, le, bu, np, iv, l429, st, sfp, cu, hu}:
191      dead.fp(st, sfp, creds)
192
193def dead_state(+row: Json.Json, +creds: String) -> IO(U32):
194  dead.row(Pollrow.row.of(row), creds)
195
196def static.cred(none: Bool) -> Maybe<&2, String>:
197  match none:
198    case True{}:
199      Some{"no_credentials"}
200    case False{}:
201      None{}
202
203def static.pick(api: Bool, +creds: String) -> Maybe<&2, String>:
204  match api:
205    case True{}:
206      Some{"api_key"}
207    case False{}:
208      static.cred(Maybe.is_none(&2, String, UCred.access_token(UCred.oauth.of(creds))))
209
210def static_of(+creds: String) -> Maybe<&2, String>:
211  static.pick(SBase.looks_like_api_key(creds), creds)
212
213def held_row(+row: Json.Json, +now: U32) -> Bool:
214  Pollrow.before.m(Pollrow.num.field("heldUntil", row), now)
215
216def expired_creds(+creds: String, +now: U32) -> Bool:
217  UCred.expired(UCred.oauth.of(creds), now)
218
219def expired.pick(on: Bool, +creds: String, +now: U32) -> Maybe<&2, String>:
220  match on:
221    case True{}:
222      Some{"token_expired"}
223    case False{}:
224      None{}
225
226# An expired active token is never refreshed here (that is Claude Code's, under
227# its own lock): it reads `token_expired` unless a held reading covers it.
228def expired_sentinel(active: Bool, +row: Json.Json, +creds: String, +now: U32) -> Maybe<&2, String>:
229  expired.pick(active && Bool.not(held_row(row, now)) && expired_creds(creds, now), creds, now)
230
231def first(a: Maybe<&2, String>, b: Maybe<&2, String>) -> Maybe<&2, String>:
232  match a:
233    case Some{x}:
234      Some{x}
235    case None{}:
236      b
237
238def dead.pick(dead: Bool) -> Maybe<&2, String>:
239  match dead:
240    case True{}:
241      Some{"relogin_required"}
242    case False{}:
243      None{}
244
245def dead_sentinel(+d: U32) -> Maybe<&2, String>:
246  dead.pick(U32.is_eq(d, 1))
247
248# One account with its stored row and the usage state read off them.
249type St is Data:
250  St{info: Info, row: Json.Json, sentinel: Maybe<&2, String>, healed: Bool}
251
252def st.mk(+i: Info, +row: Json.Json, +d: U32, +now: U32) -> St:
253  match i:
254    case Info{num, email, org_name, org_uuid, alias, disabled, active, creds}:
255      St{i, row, first(static_of(creds), first(dead_sentinel(d), expired_sentinel(active, row, creds, now))), U32.is_eq(d, 2)}
256
257def row_or_null(m: Maybe<&2, Json.Json>) -> Json.Json:
258  match m:
259    case Some{r}:
260      r
261    case None{}:
262      Json.of_pairs([])
263
264def creds_of(+i: Info) -> String:
265  match i:
266    case Info{num, email, org_name, org_uuid, alias, disabled, active, creds}:
267      creds
268
269def st.row(+i: Info, +row: Json.Json, +now: U32) -> IO(St):
270  IO.bind(U32, St, dead_state(row, creds_of(i)), d => IO.pure(St, st.mk(i, row, d, now)))
271
272def st.of(+i: Info, +rows: Json.Json, +now: U32) -> IO(St):
273  st.row(i, row_or_null(Ustore.row_of(rows, id_of(i))), now)
274
275def states(xs: List<&2, Info>, +rows: Json.Json, +now: U32) -> IO(List<&2, St>):
276  match xs:
277    case Nil{}:
278      IO.pure(List<&2, St>, [])
279    case Con{h, t}:
280      IO.bind(St, List<&2, St>, st.of(h, rows, now), s => IO.bind(List<&2, St>, List<&2, St>, states(t, rows, now), rest => IO.pure(List<&2, St>, s <> rest)))
281
282def st_slot(+s: St) -> String:
283  match s:
284    case St{info, row, sentinel, healed}:
285      slot_of(info)
286
287def st_healed(+s: St) -> Bool:
288  match s:
289    case St{info, row, sentinel, healed}:
290      healed
291
292def st_id(+s: St) -> Ustore.Id:
293  match s:
294    case St{info, row, sentinel, healed}:
295      id_of(info)
296
297
298def st_sentinel(+s: St) -> Maybe<&2, String>:
299  match s:
300    case St{info, row, sentinel, healed}:
301      sentinel