daemon/sess.bend

cswap-bend-cli@0.1.0.0 · 374 lines · 63 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/fs.bend as Fs
4import cswap-bend-cli@0.1.0.0/act.bend as Act
5
6# Is a Claude session running against a slot's session profile?
7# (switcher._live_session_pids / process_detection.scan_sessions): the records
8# <root>/sessions/<slot>-<slug(email)>/sessions/*.json each name a pid; one
9# counts only when that pid is alive and is still the process that wrote the
10# record. A record that cannot be read counts as no session (a scan, not a
11# guard). The check is ps-based, like the reference's on macOS and Linux.
12
13def ActM(-A: Type) -> Type:
14  Act.of(A)
15
16def ActM.pure(-A: Type, x: A) -> Act.of(A):
17  Act.pure(A, x)
18
19def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B):
20  Act.bind(A, B, m, f)
21
22# ---- the profile directory -------------------------------------------------------------------------
23
24def keep_char(+n: U32) -> Bool:
25  (U32.is_ge(n, 48) && U32.is_le(n, 57)) || (U32.is_ge(n, 65) && U32.is_le(n, 90)) || (U32.is_ge(n, 97) && U32.is_le(n, 122)) || U32.is_eq(n, 46) || U32.is_eq(n, 95) || U32.is_eq(n, 45)
26
27def pick_char(+k: Bool, +c: Char) -> Char:
28  match k:
29    case True{}:
30      c
31    case False{}:
32      '_'
33
34def slug_char(+c: Char) -> Char:
35  pick_char(keep_char(Char.to_u32(c)), c)
36
37def slug_list(+cs: List<&2, Char>) -> List<&2, Char>:
38  match cs:
39    case Nil{}:
40      []
41    case Con{h, t}:
42      slug_char(h) <> slug_list(t)
43
44# session.slugify_email (ASCII addresses; a non-ASCII character becomes `_`).
45def slugify(+email: String) -> String:
46  String.from_list(slug_list(String.to_list(email)))
47
48def dir_of(+root: String, +slot: String, +email: String) -> String:
49  root ++ "/sessions/" ++ slot ++ "-" ++ slugify(email) ++ "/sessions"
50
51# ---- asking ps ----------------------------------------------------------------------------------------
52
53def ps_some(+empty: Bool, +t: String) -> Maybe<&2, String>:
54  match empty:
55    case True{}:
56      None{}
57    case False{}:
58      Some{t}
59
60def ps_nonempty(+ok: Bool, +t: String) -> Maybe<&2, String>:
61  match ok:
62    case True{}:
63      ps_some(String.is_empty(t), t)
64    case False{}:
65      None{}
66
67def ps_text(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> Maybe<&2, String>:
68  match r:
69    case Fail{e}:
70      None{}
71    case Done{(st, (out, err))}:
72      ps_nonempty(U32.is_eq(st, 0), String.trim(out))
73
74# `ps -o COLS= -p PID` under LC_ALL=C TZ=UTC; None when unknowable.
75def ps(+cols: String, +pid: U32) -> IO(Maybe<&2, String>):
76  IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, Maybe<&2, String>, Process.run("/usr/bin/env", ["LC_ALL=C", "TZ=UTC", "ps", "-o", cols, "-p", U32.show(pid)], "", 4096, 5000), r => IO.pure(Maybe<&2, String>, ps_text(r)))
77
78# ---- ps -o lstart= ("Wed Sep  2 20:35:59 2026") as epoch seconds ---------------------------------
79
80def month_hit(+hit: Bool, +i: U32, +rest: Maybe<&2, U32>) -> Maybe<&2, U32>:
81  match hit:
82    case True{}:
83      Some{i}
84    case False{}:
85      rest
86
87def month_from(+ms: List<&2, String>, +m: String, +i: U32) -> Maybe<&2, U32>:
88  match ms:
89    case Nil{}:
90      None{}
91    case Con{h, t}:
92      month_hit(String.eq(h, m), i, month_from(t, m, (i + 1 : U32)))
93
94def month_idx(+m: String) -> Maybe<&2, U32>:
95  month_from(["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], m, 1)
96
97def word_keep(+empty: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>:
98  match empty:
99    case True{}:
100      rest
101    case False{}:
102      h <> rest
103
104def words(+ws: List<&2, String>) -> List<&2, String>:
105  match ws:
106    case Nil{}:
107      []
108    case Con{h, t}:
109      word_keep(String.is_empty(h), h, words(t))
110
111def sel_u(+c: Bool, +x: U32, +y: U32) -> U32:
112  match c:
113    case True{}:
114      x
115    case False{}:
116      y
117
118def days5(+a: U32, +b: U32, +doy: U32) -> U32:
119  ((((a + b : U32) + doy : U32) - 719468) : U32)
120
121def days4(+era: U32, +yoe: U32, +doy: U32) -> U32:
122  days5((era * 146097 : U32), ((((yoe * 365 : U32) + (yoe / 4 : U32) : U32) - (yoe / 100 : U32)) : U32), doy)
123
124def days3(+era: U32, +yoe: U32, +dm1: U32, +mp: U32) -> U32:
125  days4(era, yoe, ((((153 * mp : U32) + 2 : U32) / 5 : U32) + dm1 : U32))
126
127def days2(+yy: U32, +m: U32, +d: U32) -> U32:
128  days3((yy / 400 : U32), (yy % 400 : U32), (d - 1 : U32), sel_u(U32.is_gt(m, 2), (m - 3 : U32), (m + 9 : U32)))
129
130# days since 1970-01-01 of a civil date (years from 1970 on).
131def days_civil(+y: U32, +m: U32, +d: U32) -> U32:
132  days2(sel_u(U32.is_le(m, 2), (y - 1 : U32), y), m, d)
133
134def num(+s: String) -> U32:
135  Maybe.default(&2, U32, U32.read(s), 0)
136
137def clock_secs(+ps: List<&2, String>) -> Maybe<&2, U32>:
138  match ps:
139    case Con{h, Con{m, Con{s, Nil{}}}}:
140      Some{(((num(h) * 3600 : U32) + (num(m) * 60 : U32) : U32) + num(s) : U32)}
141    case _:
142      None{}
143
144def lstart_ok(+ok: Bool, +y: U32, +m: U32, +d: U32, +c: U32) -> Maybe<&2, U32>:
145  match ok:
146    case True{}:
147      Some{((days_civil(y, m, d) * 86400 : U32) + c : U32)}
148    case False{}:
149      None{}
150
151# ---- does the process at the pid still belong to the record? ----------------------------------
152
153def lstart_fields(+m: Maybe<&2, U32>, +d: Maybe<&2, U32>, +c: Maybe<&2, U32>, +y: Maybe<&2, U32>) -> Maybe<&2, U32>:
154  match m d c y:
155    case Some{mm} Some{dd} Some{cc} Some{yy}:
156      lstart_ok(U32.is_ge(yy, 1970), yy, mm, dd, cc)
157    case _ _ _ _:
158      None{}
159
160def lstart_dy(+m: Maybe<&2, U32>, +ws: List<&2, String>) -> Maybe<&2, U32>:
161  match ws:
162    case Con{day, Con{clk, Con{yr, Nil{}}}}:
163      lstart_fields(m, U32.read(day), clock_secs(String.split(clk, ':')), U32.read(yr))
164    case _:
165      None{}
166
167def lstart_dmy(+ws: List<&2, String>) -> Maybe<&2, U32>:
168  match ws:
169    case Con{mon, t}:
170      lstart_dy(month_idx(mon), t)
171    case Nil{}:
172      None{}
173
174def lstart_of(+ws: List<&2, String>) -> Maybe<&2, U32>:
175  match ws:
176    case Con{wd, t}:
177      lstart_dmy(t)
178    case Nil{}:
179      None{}
180
181def lstart_secs(+text: String) -> Maybe<&2, U32>:
182  lstart_of(words(String.split(text, ' ')))
183
184def all_digits(+cs: List<&2, Char>) -> Bool:
185  match cs:
186    case Nil{}:
187      True{}
188    case Con{c, t}:
189      Char.is_digit(c) && all_digits(t)
190
191def is_ticks(+t: String) -> Bool:
192  Bool.not(String.is_empty(t)) && all_digits(String.to_list(t))
193
194def nth_hit(+hit: Bool, +h: String, +rest: Maybe<&2, String>) -> Maybe<&2, String>:
195  match hit:
196    case True{}:
197      Some{h}
198    case False{}:
199      rest
200
201# Field 22 of /proc/<pid>/stat (counted from the last `)`), the Linux stamp.
202def nth_word(+ws: List<&2, String>, +n: U32) -> Maybe<&2, String>:
203  match ws:
204    case Nil{}:
205      None{}
206    case Con{h, t}:
207      nth_hit(U32.is_eq(n, 0), h, nth_word(t, (n - 1 : U32)))
208
209def last_part(+ps: List<&2, String>, +d: String) -> String:
210  match ps:
211    case Nil{}:
212      d
213    case Con{h, t}:
214      last_part(t, h)
215
216def stat_ticks(+text: String) -> Maybe<&2, String>:
217  nth_word(words(String.split(last_part(String.split(text, ')'), ""), ' ')), 19)
218
219def ticks_match(+want: String, +m: Maybe<&2, String>) -> Bool:
220  match m:
221    case None{}:
222      True{}
223    case Some{t}:
224      String.eq(t, want)
225
226def stat_text(r: Fs.Res(String)) -> Maybe<&2, String>:
227  match r:
228    case Fail{e}:
229      None{}
230    case Done{t}:
231      stat_ticks(t)
232
233def matches_ticks(+pid: U32, +want: String) -> IO(Bool):
234  IO.bind(Fs.Res(String), Bool, Fs.read_file("/proc/" ++ U32.show(pid) ++ "/stat"), r => IO.pure(Bool, ticks_match(want, stat_text(r))))
235
236def looks_claude(+m: Maybe<&2, String>) -> Bool:
237  match m:
238    case None{}:
239      True{}
240    case Some{t}:
241      String.contains(String.to_lower(t), "claude")
242
243def stranger2(+recent: Bool, +pid: U32) -> IO(Bool):
244  match recent:
245    case True{}:
246      IO.pure(Bool, True{})
247    case False{}:
248      IO.bind(Maybe<&2, String>, Bool, ps("comm=,args=", pid), t => IO.pure(Bool, looks_claude(t)))
249
250# Only a stranger that started later than the record's claude disqualifies it.
251def stranger(+recorded: U32, +started: Maybe<&2, U32>, +pid: U32) -> IO(Bool):
252  match started:
253    case None{}:
254      IO.pure(Bool, True{})
255    case Some{st}:
256      stranger2(U32.is_le(st, (recorded + 120 : U32)), pid)
257
258def started_of(+m: Maybe<&2, String>) -> Maybe<&2, U32>:
259  match m:
260    case None{}:
261      None{}
262    case Some{t}:
263      lstart_secs(t)
264
265def by_lstart(+pid: U32, +rec: Maybe<&2, U32>) -> IO(Bool):
266  match rec:
267    case None{}:
268      IO.pure(Bool, True{})
269    case Some{recorded}:
270      IO.bind(Maybe<&2, String>, Bool, ps("lstart=", pid), t => stranger(recorded, started_of(t), pid))
271
272def stamped2(+ticks: Bool, +pid: U32, +stamp: String) -> IO(Bool):
273  match ticks:
274    case True{}:
275      matches_ticks(pid, stamp)
276    case False{}:
277      by_lstart(pid, lstart_secs(stamp))
278
279def stamped(+pid: U32, +stamp: String) -> IO(Bool):
280  stamped2(is_ticks(stamp), pid, stamp)
281
282def with_stamp(+pid: U32, +m: Maybe<&2, String>) -> IO(Bool):
283  match m:
284    case None{}:
285      IO.pure(Bool, True{})
286    case Some{stamp}:
287      stamped(pid, stamp)
288
289def alive_then(+up: Bool, +pid: U32, +stamp: Maybe<&2, String>) -> IO(Bool):
290  match up:
291    case True{}:
292      with_stamp(pid, stamp)
293    case False{}:
294      IO.pure(Bool, False{})
295
296def pid_live(+pid: U32, +stamp: Maybe<&2, String>) -> IO(Bool):
297  IO.bind(Maybe<&2, String>, Bool, ps("pid=", pid), t => alive_then(Maybe.is_some(&2, String, t), pid, stamp))
298
299# ---- one record ---------------------------------------------------------------------------------
300
301def stamp_of(+j: Json.Json) -> Maybe<&2, String>:
302  Json.as_string(Maybe.default(&2, Json.Json, Json.get("procStart", j), Json.JNull{}))
303
304def pid_of(+j: Json.Json) -> Maybe<&2, U32>:
305  Json.as_u32(Maybe.default(&2, Json.Json, Json.get("pid", j), Json.JNull{}))
306
307def pid_live_gt1(+ok: Bool, +p: U32, +stamp: Maybe<&2, String>) -> IO(Bool):
308  match ok:
309    case True{}:
310      pid_live(p, stamp)
311    case False{}:
312      IO.pure(Bool, False{})
313
314def record_live2(+pid: Maybe<&2, U32>, +stamp: Maybe<&2, String>) -> IO(Bool):
315  match pid:
316    case None{}:
317      IO.pure(Bool, False{})
318    case Some{p}:
319      pid_live_gt1(U32.is_gt(p, 1), p, stamp)
320
321def record_live(r: Result<&2, &2, String, Json.Json>) -> IO(Bool):
322  match r:
323    case Fail{e}:
324      IO.pure(Bool, False{})
325    case Done{+j}:
326      record_live2(pid_of(j), stamp_of(j))
327
328def file_live(r: Fs.Res(String)) -> IO(Bool):
329  match r:
330    case Fail{e}:
331      IO.pure(Bool, False{})
332    case Done{t}:
333      record_live(Json.parse(t))
334
335# ---- the directory ---------------------------------------------------------------------------------------
336
337def any_live(+dir: String, +names: List<&2, String>) -> IO(Bool):
338  match names:
339    case Nil{}:
340      IO.pure(Bool, False{})
341    case Con{n, t}:
342      IO.bind(Fs.Res(String), Bool, Fs.read_file(dir ++ "/" ++ n), r => IO.bind(Bool, Bool, file_live(r), hit => IO.bind(Bool, Bool, any_live(dir, t), rest => IO.pure(Bool, hit || rest))))
343
344def rest_live(+hit: Bool, +dir: String, +t: List<&2, String>) -> IO(Bool):
345  match hit:
346    case True{}:
347      IO.pure(Bool, True{})
348    case False{}:
349      any_live(dir, t)
350
351def json_keep(+ok: Bool, +h: String, +rest: List<&2, String>) -> List<&2, String>:
352  match ok:
353    case True{}:
354      h <> rest
355    case False{}:
356      rest
357
358def json_names(+ns: List<&2, String>) -> List<&2, String>:
359  match ns:
360    case Nil{}:
361      []
362    case Con{h, t}:
363      json_keep(String.ends_with(h, ".json"), h, json_names(t))
364
365def scan(+dir: String, r: Fs.Res(List<&2, String>)) -> IO(Bool):
366  match r:
367    case Fail{e}:
368      IO.pure(Bool, False{})
369    case Done{ns}:
370      any_live(dir, json_names(ns))
371
372# Is a Claude session live against this slot's session profile?
373def live(+root: String, +slot: String, +email: String) -> IO(Bool):
374  IO.bind(Fs.Res(List<&2, String>), Bool, Fs.list_dir(dir_of(root, slot, email)), r => scan(dir_of(root, slot, email), r))