1 import Base 2 import ./urec.bend as Urec 3 import ./pollrow.bend as Pollrow 4 import ./usage/cred.bend as UCred 5 import ./store/io.bend as SIo 6 import ./store/seq.bend as SSeq 7 import ./store/base.bend as SBase 8 import ./json.bend as Json 9 import ./clock.bend as Clock 10 import ./fs.bend as Fs 11 import ./act.bend as Act 12 import ./poll.bend as Poll 13 import ./ustore.bend as Ustore 14 import ./usage.bend as Usage 15 import ./view.bend as View 16 import ./store.bend as Store 17 18 # API (also the daemon's collector; do not copy it): 19 # gather(ctx, seq) -> Act(List) every account in `sequence`, the active one with the live credential 20 # collect(ctx, infos, now, models, threshold_tenths, tick) -> Act(List) 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 30 def ActM(-A: Type) -> Type: 31 Act.of(A) 32 33 def ActM.pure(-A: Type, x: A) -> Act.of(A): 34 Act.pure(A, x) 35 36 def 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. 41 type 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 45 def 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 50 def 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 55 def 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. 63 def 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 66 def 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 69 def 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 76 def 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 79 def 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 86 def jstr_of(+rec: Json.Json, +k: String) -> String: 87 SSeq.field_str(rec, k) 88 89 def flag.of(m: Maybe<&2, Json.Json>) -> Bool: 90 match m: 91 case Some{Json.JBool{True{}}}: 92 True{} 93 case _: 94 False{} 95 96 def rec_flag(+rec: Json.Json, +k: String) -> Bool: 97 flag.of(Json.get(k, rec)) 98 99 def 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 102 def 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 109 def 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 116 def 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 119 def 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 127 def 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. 132 def 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 136 def sha.of2(oe: String & String) -> String: 137 (o, e) = oe 138 String.take(o, 64n) 139 140 def sha.of1(r: U32 & (String & String)) -> String: 141 (st, oe) = r 142 sha.of2(oe) 143 144 def 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). 152 def 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 155 def fp.rt(+tag: String, +text: String) -> IO(String): 156 IO.bind(String, String, sha256(text), h => IO.pure(String, tag ++ h)) 157 158 def 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. 167 def 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. 171 def 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 178 def 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 185 def dead.fp(strikes: U32, struck: Maybe<&2, String>, +creds: String) -> IO(U32): 186 dead.go(U32.is_ge(strikes, 1), struck, creds) 187 188 def 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 193 def dead_state(+row: Json.Json, +creds: String) -> IO(U32): 194 dead.row(Pollrow.row.of(row), creds) 195 196 def static.cred(none: Bool) -> Maybe<&2, String>: 197 match none: 198 case True{}: 199 Some{"no_credentials"} 200 case False{}: 201 None{} 202 203 def 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 210 def static_of(+creds: String) -> Maybe<&2, String>: 211 static.pick(SBase.looks_like_api_key(creds), creds) 212 213 def held_row(+row: Json.Json, +now: U32) -> Bool: 214 Pollrow.before.m(Pollrow.num.field("heldUntil", row), now) 215 216 def expired_creds(+creds: String, +now: U32) -> Bool: 217 UCred.expired(UCred.oauth.of(creds), now) 218 219 def 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. 228 def 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 231 def 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 238 def dead.pick(dead: Bool) -> Maybe<&2, String>: 239 match dead: 240 case True{}: 241 Some{"relogin_required"} 242 case False{}: 243 None{} 244 245 def 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. 249 type St is Data: 250 St{info: Info, row: Json.Json, sentinel: Maybe<&2, String>, healed: Bool} 251 252 def 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 257 def 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 264 def creds_of(+i: Info) -> String: 265 match i: 266 case Info{num, email, org_name, org_uuid, alias, disabled, active, creds}: 267 creds 268 269 def 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 272 def 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 275 def 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 282 def st_slot(+s: St) -> String: 283 match s: 284 case St{info, row, sentinel, healed}: 285 slot_of(info) 286 287 def st_healed(+s: St) -> Bool: 288 match s: 289 case St{info, row, sentinel, healed}: 290 healed 291 292 def st_id(+s: St) -> Ustore.Id: 293 match s: 294 case St{info, row, sentinel, healed}: 295 id_of(info) 296 297 298 def st_sentinel(+s: St) -> Maybe<&2, String>: 299 match s: 300 case St{info, row, sentinel, healed}: 301 sentinel