cc-session-control 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cc_session_control/__init__.py +3 -0
- cc_session_control/__main__.py +5 -0
- cc_session_control/actions/__init__.py +0 -0
- cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control/actions/session_ops.py +150 -0
- cc_session_control/app.py +264 -0
- cc_session_control/cli.py +288 -0
- cc_session_control/clipboard.py +44 -0
- cc_session_control/config.py +132 -0
- cc_session_control/data/__init__.py +0 -0
- cc_session_control/data/agents.py +12 -0
- cc_session_control/data/cleanup.py +402 -0
- cc_session_control/data/environments.py +444 -0
- cc_session_control/data/liveness.py +140 -0
- cc_session_control/data/proc.py +214 -0
- cc_session_control/data/rc.py +411 -0
- cc_session_control/data/registry.py +155 -0
- cc_session_control/data/sessions.py +188 -0
- cc_session_control/data/snapshot.py +115 -0
- cc_session_control/models.py +170 -0
- cc_session_control/views/__init__.py +0 -0
- cc_session_control/views/_session_row.py +145 -0
- cc_session_control/views/agents.py +293 -0
- cc_session_control/views/rc.py +374 -0
- cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0.dist-info/METADATA +115 -0
- cc_session_control-0.4.0.dist-info/RECORD +31 -0
- cc_session_control-0.4.0.dist-info/WHEEL +5 -0
- cc_session_control-0.4.0.dist-info/entry_points.txt +2 -0
- cc_session_control-0.4.0.dist-info/licenses/LICENSE +21 -0
- cc_session_control-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Bridge-environment ledger — a PASSIVE, observe-and-forget store (R6, D4).
|
|
2
|
+
|
|
3
|
+
Claude Code keeps only the *current* bridge binding for a session/agent (a
|
|
4
|
+
single overwritten field), so toggled-away or historically minted cloud
|
|
5
|
+
environments vanish from on-disk state. csctl maintains its own append-only
|
|
6
|
+
ledger so those environments stay traceable enough to be deleted by hand on
|
|
7
|
+
claude.ai/code — there is NO local deregister, this module never deletes a
|
|
8
|
+
cloud environment.
|
|
9
|
+
|
|
10
|
+
Design invariants:
|
|
11
|
+
- **Passive store.** Callers push observations in (`upsert(records)`); this
|
|
12
|
+
module never reaches up to collect them. It must NOT import `rc`
|
|
13
|
+
(`environments` is below `rc` in the import DAG; `rc` calls `upsert` one-way
|
|
14
|
+
in Phase 5 for the `env_*` namespace). `observe()` is a convenience builder
|
|
15
|
+
that reads the lower-level `registry` (and accepts `env_*` records passed in,
|
|
16
|
+
never collected here).
|
|
17
|
+
- **Two observation tiers.** `observe()` is the bridge-truthy FILE-REFERENCED
|
|
18
|
+
set — what defines ledger MEMBERSHIP (an env exists in the cloud while any
|
|
19
|
+
on-disk file references it, alive or zombie). `observe_live()` alive-gates the
|
|
20
|
+
same sources for the CURRENT/bound DISPLAY. Orphans (manual-delete
|
|
21
|
+
candidates) are `ledger − file-referenced`: an env the ledger remembers but no
|
|
22
|
+
file references anymore (RC toggled off, job removed, server stopped).
|
|
23
|
+
- **Three namespaces, namespace-scoped dedup.** The merge key is
|
|
24
|
+
`(prefix, key)`: within `cse_*` a resume pair shares one suffix → one env;
|
|
25
|
+
`session_*` and `cse_*` never merge (their suffixes never coincide in
|
|
26
|
+
practice); `env_*` ids are opaque and each unique. Dedup is WITHIN a
|
|
27
|
+
namespace, never cross-view.
|
|
28
|
+
- **Write-on-change + atomic + single-writer.** The read-modify-write runs
|
|
29
|
+
under an advisory `flock` (degrades gracefully where unavailable), the
|
|
30
|
+
resulting ledger is serialized canonically and only rewritten when it
|
|
31
|
+
differs from disk, and the write is `tmp + os.replace` atomic.
|
|
32
|
+
- **Retention/compaction.** Entries older than `_RETENTION_SECONDS` (90d) are
|
|
33
|
+
dropped; if still over `_MAX_ENTRIES`, the most-recently-seen are kept. A
|
|
34
|
+
re-observed env always carries `last_seen == now` so it survives compaction.
|
|
35
|
+
|
|
36
|
+
Everything swallows errors → safe empties: a missing or corrupt ledger never
|
|
37
|
+
crashes (returns `[]` / no-ops).
|
|
38
|
+
|
|
39
|
+
Known limitation (capability red line): the ledger cannot back-fill
|
|
40
|
+
environments minted while csctl was not running — there is no `null`/history on
|
|
41
|
+
disk to recover them — so the orphan / manual-delete list is inherently
|
|
42
|
+
incomplete.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import contextlib
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
from collections.abc import Iterator
|
|
51
|
+
from dataclasses import replace
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
from ..config import cfg
|
|
55
|
+
from ..models import AgentJob, BridgeEnv, EnvRecord, RCServer, SessionProc
|
|
56
|
+
from . import proc, registry
|
|
57
|
+
|
|
58
|
+
try: # POSIX advisory locking; absent on Windows → degrade to no lock.
|
|
59
|
+
import fcntl
|
|
60
|
+
except ImportError: # pragma: no cover - platform dependent
|
|
61
|
+
fcntl = None # type: ignore[assignment]
|
|
62
|
+
|
|
63
|
+
# Drop entries unseen for longer than this; cap total entries beyond that.
|
|
64
|
+
_RETENTION_SECONDS = 90 * 86400
|
|
65
|
+
_MAX_ENTRIES = 500
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- bridge id parsing -----------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _split_bridge(bridge: str) -> tuple[str, str]:
|
|
71
|
+
"""`cse_abc` -> (`cse`, `abc`); the suffix is the namespace-local env id."""
|
|
72
|
+
prefix, sep, suffix = bridge.partition("_")
|
|
73
|
+
if not sep:
|
|
74
|
+
return "", ""
|
|
75
|
+
return prefix, suffix
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- observation builder (reads registry only, never rc) -------------------
|
|
79
|
+
|
|
80
|
+
def observe(
|
|
81
|
+
session_procs: list[SessionProc] | None = None,
|
|
82
|
+
agent_jobs: list[AgentJob] | None = None,
|
|
83
|
+
rc_servers: list[RCServer] | None = None,
|
|
84
|
+
max_age: float = 5.0,
|
|
85
|
+
) -> list[EnvRecord]:
|
|
86
|
+
"""FILE-REFERENCED bridge envs — the ledger MEMBERSHIP set (R6).
|
|
87
|
+
|
|
88
|
+
Every env an on-disk file references right now: a session's truthy
|
|
89
|
+
`bridgeSessionId` (`session_*`, alive OR zombie), a job's `cse_*` env suffix,
|
|
90
|
+
plus any `env_*` captured from the `rc_servers` passed in. This is the set
|
|
91
|
+
that DEFINES ledger membership — an env exists in the cloud as long as a file
|
|
92
|
+
references it, regardless of liveness. Contrast `observe_live()`, which
|
|
93
|
+
alive-gates the same sources for the CURRENT/bound display; orphans are
|
|
94
|
+
`ledger − file-referenced`.
|
|
95
|
+
|
|
96
|
+
Pure when the sources are supplied (snapshot path / tests); self-reads the
|
|
97
|
+
registry when they are None (CLI / no-snapshot fallback). `env_*` is only ever
|
|
98
|
+
passed in (this module never imports rc). Swallows errors → [].
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
if session_procs is None:
|
|
102
|
+
session_procs = registry.read_session_procs(max_age=max_age)
|
|
103
|
+
if agent_jobs is None:
|
|
104
|
+
agent_jobs = registry.read_agent_jobs(max_age=max_age)
|
|
105
|
+
records: list[EnvRecord] = []
|
|
106
|
+
for sp in session_procs:
|
|
107
|
+
if not sp.bridge:
|
|
108
|
+
continue
|
|
109
|
+
prefix, key = _split_bridge(sp.bridge)
|
|
110
|
+
if prefix and key:
|
|
111
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
|
|
112
|
+
for job in agent_jobs:
|
|
113
|
+
if job.env_suffix:
|
|
114
|
+
records.append(
|
|
115
|
+
EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
|
|
116
|
+
)
|
|
117
|
+
for srv in rc_servers or []:
|
|
118
|
+
if srv.env_id:
|
|
119
|
+
prefix, sep, key = srv.env_id.partition("_")
|
|
120
|
+
if sep and prefix and key:
|
|
121
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
122
|
+
return records
|
|
123
|
+
except Exception:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def observe_live(
|
|
128
|
+
session_procs: list[SessionProc] | None = None,
|
|
129
|
+
agent_jobs: list[AgentJob] | None = None,
|
|
130
|
+
rc_servers: list[RCServer] | None = None,
|
|
131
|
+
max_age: float = 5.0,
|
|
132
|
+
) -> list[EnvRecord]:
|
|
133
|
+
"""Alive-gated "currently exposed" bridge envs (R3/R6) — the CURRENT set.
|
|
134
|
+
|
|
135
|
+
Where `observe()` is a bridge-truthy passive collector, this lifts the
|
|
136
|
+
`_is_rc_exposed` predicate to the env ledger: a bridge is CURRENT only when
|
|
137
|
+
its owner is alive — `session_*` gated by proc-alive, `cse_*` by a proc-alive
|
|
138
|
+
session sharing the job sid (host-alive), `env_*` by a running RC server. So a
|
|
139
|
+
zombie session's stale `bridgeSessionId` is NOT reported as current — it falls
|
|
140
|
+
through to `orphan_envs` (a manual-delete candidate) instead of overstating
|
|
141
|
+
the bound count.
|
|
142
|
+
|
|
143
|
+
Pure when `session_procs`/`agent_jobs` are supplied (the snapshot path passes
|
|
144
|
+
its already-liveness-resolved data — DI for tests); reads the registry +
|
|
145
|
+
`/proc` itself when they are None (CLI / no-snapshot view fallback). Swallows
|
|
146
|
+
errors → [].
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
if session_procs is None:
|
|
150
|
+
session_procs = [
|
|
151
|
+
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
152
|
+
for sp in registry.read_session_procs(max_age=max_age)
|
|
153
|
+
]
|
|
154
|
+
if agent_jobs is None:
|
|
155
|
+
agent_jobs = registry.read_agent_jobs(max_age=max_age)
|
|
156
|
+
alive_sids = {sp.sid for sp in session_procs if sp.proc_alive}
|
|
157
|
+
records: list[EnvRecord] = []
|
|
158
|
+
for sp in session_procs:
|
|
159
|
+
if sp.bridge and sp.proc_alive:
|
|
160
|
+
prefix, key = _split_bridge(sp.bridge)
|
|
161
|
+
if prefix and key:
|
|
162
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=sp.sid))
|
|
163
|
+
for job in agent_jobs:
|
|
164
|
+
if job.env_suffix and (job.host_alive or job.sid in alive_sids):
|
|
165
|
+
records.append(
|
|
166
|
+
EnvRecord(prefix="cse", key=job.env_suffix, bound_sid=job.sid)
|
|
167
|
+
)
|
|
168
|
+
for srv in rc_servers or []:
|
|
169
|
+
if srv.env_id and srv.status == "running":
|
|
170
|
+
prefix, sep, key = srv.env_id.partition("_")
|
|
171
|
+
if sep and prefix and key:
|
|
172
|
+
records.append(EnvRecord(prefix=prefix, key=key, bound_sid=None))
|
|
173
|
+
return records
|
|
174
|
+
except Exception:
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- ledger IO (parse / serialize / atomic write / lock) -------------------
|
|
179
|
+
|
|
180
|
+
def _read_raw() -> str:
|
|
181
|
+
try:
|
|
182
|
+
with open(cfg.environments_ledger, errors="ignore") as fh:
|
|
183
|
+
return fh.read()
|
|
184
|
+
except Exception:
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_ledger(text: str) -> dict[tuple[str, str], BridgeEnv]:
|
|
189
|
+
"""Parse ledger text into a (prefix, key) -> BridgeEnv map. Skips bad lines.
|
|
190
|
+
|
|
191
|
+
Persisted `status` is ignored (recomputed against the live observation), so
|
|
192
|
+
only the five durable fields are read back.
|
|
193
|
+
"""
|
|
194
|
+
out: dict[tuple[str, str], BridgeEnv] = {}
|
|
195
|
+
for line in text.splitlines():
|
|
196
|
+
line = line.strip()
|
|
197
|
+
if not line:
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
d = json.loads(line)
|
|
201
|
+
except Exception:
|
|
202
|
+
continue
|
|
203
|
+
prefix = d.get("prefix")
|
|
204
|
+
key = d.get("key")
|
|
205
|
+
if not prefix or not key:
|
|
206
|
+
continue
|
|
207
|
+
out[(prefix, key)] = BridgeEnv(
|
|
208
|
+
prefix=str(prefix),
|
|
209
|
+
key=str(key),
|
|
210
|
+
bound_sid=d.get("bound_sid"),
|
|
211
|
+
first_seen=_as_float(d.get("first_seen")),
|
|
212
|
+
last_seen=_as_float(d.get("last_seen")),
|
|
213
|
+
)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _as_float(v: Any) -> float:
|
|
218
|
+
try:
|
|
219
|
+
return float(v)
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
return 0.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _serialize(entries: list[BridgeEnv]) -> str:
|
|
225
|
+
"""Canonical JSONL (sorted by id, sorted keys) so write-on-change is exact."""
|
|
226
|
+
lines: list[str] = []
|
|
227
|
+
for e in sorted(entries, key=lambda e: (e.prefix, e.key)):
|
|
228
|
+
d = {
|
|
229
|
+
"prefix": e.prefix,
|
|
230
|
+
"key": e.key,
|
|
231
|
+
"bound_sid": e.bound_sid,
|
|
232
|
+
"first_seen": e.first_seen,
|
|
233
|
+
"last_seen": e.last_seen,
|
|
234
|
+
}
|
|
235
|
+
lines.append(json.dumps(d, sort_keys=True, ensure_ascii=False))
|
|
236
|
+
return ("\n".join(lines) + "\n") if lines else ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _atomic_write(text: str) -> None:
|
|
240
|
+
cfg.config_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
path = str(cfg.environments_ledger)
|
|
242
|
+
tmp = path + ".tmp"
|
|
243
|
+
with open(tmp, "w") as fh:
|
|
244
|
+
fh.write(text)
|
|
245
|
+
fh.flush()
|
|
246
|
+
os.fsync(fh.fileno())
|
|
247
|
+
os.replace(tmp, path)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@contextlib.contextmanager
|
|
251
|
+
def _write_lock() -> Iterator[None]:
|
|
252
|
+
"""Advisory single-writer lock around a read-modify-write.
|
|
253
|
+
|
|
254
|
+
Uses a dedicated `.lock` file (never replaced) so the lock survives the
|
|
255
|
+
ledger's atomic `os.replace`. Degrades to no-op locking where `fcntl` is
|
|
256
|
+
unavailable (Windows) — the atomic rename still prevents a torn file.
|
|
257
|
+
"""
|
|
258
|
+
fh = None
|
|
259
|
+
try:
|
|
260
|
+
cfg.config_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
fh = open(str(cfg.environments_ledger) + ".lock", "w")
|
|
262
|
+
if fcntl is not None:
|
|
263
|
+
try:
|
|
264
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
yield
|
|
268
|
+
except Exception:
|
|
269
|
+
yield
|
|
270
|
+
finally:
|
|
271
|
+
if fh is not None:
|
|
272
|
+
if fcntl is not None:
|
|
273
|
+
try:
|
|
274
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
fh.close()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --- merge / compaction ----------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def _dedup_records(records: list[EnvRecord]) -> list[EnvRecord]:
|
|
283
|
+
"""Collapse records by (prefix, key) — within-namespace dedup (resume pairs).
|
|
284
|
+
|
|
285
|
+
Picks a deterministic canonical `bound_sid` (the smallest non-empty sid) so
|
|
286
|
+
the merge result is stable regardless of the observation order (registry
|
|
287
|
+
glob order is not sorted), keeping write-on-change from flapping.
|
|
288
|
+
"""
|
|
289
|
+
grouped: dict[tuple[str, str], list[EnvRecord]] = {}
|
|
290
|
+
for r in records:
|
|
291
|
+
if not r.prefix or not r.key:
|
|
292
|
+
continue
|
|
293
|
+
grouped.setdefault((r.prefix, r.key), []).append(r)
|
|
294
|
+
out: list[EnvRecord] = []
|
|
295
|
+
for (prefix, key), recs in grouped.items():
|
|
296
|
+
sids = sorted({r.bound_sid for r in recs if r.bound_sid})
|
|
297
|
+
out.append(EnvRecord(prefix=prefix, key=key, bound_sid=sids[0] if sids else None))
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _merge(
|
|
302
|
+
ledger: dict[tuple[str, str], BridgeEnv],
|
|
303
|
+
records: list[EnvRecord],
|
|
304
|
+
now: float,
|
|
305
|
+
) -> dict[tuple[str, str], BridgeEnv]:
|
|
306
|
+
for rec in _dedup_records(records):
|
|
307
|
+
k = (rec.prefix, rec.key)
|
|
308
|
+
existing = ledger.get(k)
|
|
309
|
+
if existing is None:
|
|
310
|
+
ledger[k] = BridgeEnv(
|
|
311
|
+
prefix=rec.prefix,
|
|
312
|
+
key=rec.key,
|
|
313
|
+
bound_sid=rec.bound_sid,
|
|
314
|
+
first_seen=now,
|
|
315
|
+
last_seen=now,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
existing.bound_sid = rec.bound_sid
|
|
319
|
+
existing.last_seen = now
|
|
320
|
+
return ledger
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _membership(ledger: dict[tuple[str, str], BridgeEnv]) -> set[tuple[str, str, str | None, float]]:
|
|
324
|
+
"""Durable identity of the ledger, EXCLUDING `last_seen` (M2).
|
|
325
|
+
|
|
326
|
+
Two ledgers with the same membership but different `last_seen` are
|
|
327
|
+
write-equivalent: re-observing the same envs must not rewrite the file just
|
|
328
|
+
because the clock advanced. `first_seen` and `bound_sid` ARE part of identity
|
|
329
|
+
(a new entry, a dropped entry, or a resume re-bind is a real change).
|
|
330
|
+
"""
|
|
331
|
+
return {(e.prefix, e.key, e.bound_sid, e.first_seen) for e in ledger.values()}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _compact(
|
|
335
|
+
ledger: dict[tuple[str, str], BridgeEnv], now: float
|
|
336
|
+
) -> dict[tuple[str, str], BridgeEnv]:
|
|
337
|
+
cutoff = now - _RETENTION_SECONDS
|
|
338
|
+
kept = {k: e for k, e in ledger.items() if e.last_seen >= cutoff}
|
|
339
|
+
if len(kept) > _MAX_ENTRIES:
|
|
340
|
+
newest = sorted(kept.values(), key=lambda e: e.last_seen, reverse=True)
|
|
341
|
+
kept = {(e.prefix, e.key): e for e in newest[:_MAX_ENTRIES]}
|
|
342
|
+
return kept
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- public API ------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def upsert(records: list[EnvRecord], now: float | None = None) -> None:
|
|
348
|
+
"""Merge observed env records into the ledger (passive store, R6/D4).
|
|
349
|
+
|
|
350
|
+
Sets `first_seen` on insert, advances `last_seen` to `now` on re-observation
|
|
351
|
+
(`now` injectable for deterministic tests), dedups within a namespace, and
|
|
352
|
+
compacts — all under an advisory lock, via an atomic `tmp + replace`.
|
|
353
|
+
|
|
354
|
+
Write-on-change ignores `last_seen` (M2): the file is rewritten only when the
|
|
355
|
+
MEMBERSHIP changes (an env added/dropped, a re-bind, a new `first_seen`) or
|
|
356
|
+
when the on-disk text is not already canonical (corrupt / legacy line cleanup).
|
|
357
|
+
A pure clock advance on otherwise-identical membership does NOT rewrite, so a
|
|
358
|
+
steady-state refresh cycle leaves the file (and its mtime) untouched. The
|
|
359
|
+
persisted copy still carries the advanced `last_seen` whenever a real write
|
|
360
|
+
happens. Swallows all errors.
|
|
361
|
+
"""
|
|
362
|
+
import time
|
|
363
|
+
|
|
364
|
+
ts = time.time() if now is None else now
|
|
365
|
+
try:
|
|
366
|
+
with _write_lock():
|
|
367
|
+
old_text = _read_raw()
|
|
368
|
+
ledger = _parse_ledger(old_text)
|
|
369
|
+
# Snapshot identity BEFORE _merge mutates bound_sid/last_seen in place.
|
|
370
|
+
old_sig = _membership(ledger)
|
|
371
|
+
non_canonical = _serialize(list(ledger.values())) != old_text
|
|
372
|
+
ledger = _merge(ledger, records, ts)
|
|
373
|
+
ledger = _compact(ledger, ts)
|
|
374
|
+
if _membership(ledger) != old_sig or non_canonical:
|
|
375
|
+
_atomic_write(_serialize(list(ledger.values())))
|
|
376
|
+
except Exception:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _read_ledger() -> dict[tuple[str, str], BridgeEnv]:
|
|
381
|
+
return _parse_ledger(_read_raw())
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def current_envs(observed: list[EnvRecord]) -> list[BridgeEnv]:
|
|
385
|
+
"""Envs bound to something observed right now (status='current').
|
|
386
|
+
|
|
387
|
+
Classifies the ledger against the observation. An observed env not yet in
|
|
388
|
+
the ledger (caller queried before `upsert`) is still reported current so the
|
|
389
|
+
result is correct regardless of call order. Sorted newest-seen first.
|
|
390
|
+
"""
|
|
391
|
+
obs = {(r.prefix, r.key): r for r in observed if r.prefix and r.key}
|
|
392
|
+
ledger = _read_ledger()
|
|
393
|
+
out: list[BridgeEnv] = []
|
|
394
|
+
seen: set[tuple[str, str]] = set()
|
|
395
|
+
for k, env in ledger.items():
|
|
396
|
+
if k in obs:
|
|
397
|
+
env.status = "current"
|
|
398
|
+
out.append(env)
|
|
399
|
+
seen.add(k)
|
|
400
|
+
for k, rec in obs.items():
|
|
401
|
+
if k not in seen:
|
|
402
|
+
out.append(BridgeEnv(prefix=rec.prefix, key=rec.key,
|
|
403
|
+
bound_sid=rec.bound_sid, status="current"))
|
|
404
|
+
return sorted(out, key=lambda e: e.last_seen, reverse=True)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def orphan_envs(observed: list[EnvRecord]) -> list[BridgeEnv]:
|
|
408
|
+
"""Ledger entries NOT in the current observation (status='orphan').
|
|
409
|
+
|
|
410
|
+
Pass the FILE-REFERENCED set (`observe()`) here so orphans are precisely
|
|
411
|
+
`ledger − file-referenced`: envs the ledger remembers but no on-disk file
|
|
412
|
+
references anymore (RC toggled off, job removed, server stopped). These are
|
|
413
|
+
the manual-delete candidates: csctl cannot deregister a cloud environment, so
|
|
414
|
+
the user removes them on claude.ai/code. Sorted newest-seen first.
|
|
415
|
+
(Inherently incomplete — see the module docstring's red line.)
|
|
416
|
+
"""
|
|
417
|
+
obs_keys = {(r.prefix, r.key) for r in observed if r.prefix and r.key}
|
|
418
|
+
out: list[BridgeEnv] = []
|
|
419
|
+
for k, env in _read_ledger().items():
|
|
420
|
+
if k not in obs_keys:
|
|
421
|
+
env.status = "orphan"
|
|
422
|
+
out.append(env)
|
|
423
|
+
return sorted(out, key=lambda e: e.last_seen, reverse=True)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def manual_delete_list(observed: list[EnvRecord] | None = None) -> list[dict[str, Any]]:
|
|
427
|
+
"""Orphans formatted for manual deletion on claude.ai/code (R6).
|
|
428
|
+
|
|
429
|
+
Each row carries the full namespaced `env_id` (incl. `env_*`), `prefix`,
|
|
430
|
+
`key`, last-known `bound_sid`, and `last_seen`. With no observation passed,
|
|
431
|
+
every ledger entry is treated as a candidate. There is NO deregister action
|
|
432
|
+
— this is a checklist for the human, observe-and-forget only.
|
|
433
|
+
"""
|
|
434
|
+
orphans = orphan_envs(observed or [])
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
"env_id": e.env_id,
|
|
438
|
+
"prefix": e.prefix,
|
|
439
|
+
"key": e.key,
|
|
440
|
+
"bound_sid": e.bound_sid,
|
|
441
|
+
"last_seen": e.last_seen,
|
|
442
|
+
}
|
|
443
|
+
for e in orphans
|
|
444
|
+
]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Session liveness — the single authority.
|
|
2
|
+
|
|
3
|
+
Owns the ONE module-global cache for `claude agents --json` (mirrored by the
|
|
4
|
+
`agents.py` re-export shim so terminate's invalidate and scan's read share it).
|
|
5
|
+
`live_index` is a pure merge of already-fetched liveness inputs (registry
|
|
6
|
+
session files with injected proc liveness + `claude agents --json`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from ..models import LiveInfo, SessionProc
|
|
16
|
+
|
|
17
|
+
_cache: dict[str, int | None] | None = None
|
|
18
|
+
_cache_time: float = 0.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def alive_map(max_age: float = 5.0) -> dict[str, int | None]:
|
|
22
|
+
"""Return {session_id: pid} for all known agents. Cached for max_age seconds."""
|
|
23
|
+
global _cache, _cache_time
|
|
24
|
+
now = time.monotonic()
|
|
25
|
+
if _cache is not None and (now - _cache_time) < max_age:
|
|
26
|
+
return _cache
|
|
27
|
+
try:
|
|
28
|
+
out = subprocess.run(
|
|
29
|
+
["claude", "agents", "--json"],
|
|
30
|
+
capture_output=True, text=True, timeout=10,
|
|
31
|
+
).stdout
|
|
32
|
+
result = {
|
|
33
|
+
a.get("sessionId"): a.get("pid")
|
|
34
|
+
for a in json.loads(out or "[]")
|
|
35
|
+
if a.get("sessionId")
|
|
36
|
+
}
|
|
37
|
+
except Exception:
|
|
38
|
+
result = {}
|
|
39
|
+
_cache = result
|
|
40
|
+
_cache_time = now
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def invalidate_cache() -> None:
|
|
45
|
+
global _cache
|
|
46
|
+
_cache = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _source_of(entrypoint: str, kind: str) -> str:
|
|
50
|
+
"""Coarse source bucket from the registry entrypoint/kind (D9)."""
|
|
51
|
+
if kind == "bg":
|
|
52
|
+
return "bg"
|
|
53
|
+
if entrypoint == "claude-vscode":
|
|
54
|
+
return "vscode"
|
|
55
|
+
if entrypoint == "sdk-ts":
|
|
56
|
+
return "sdk"
|
|
57
|
+
return "cli"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_rc_exposed(bridge: str | None, pid_alive: bool) -> bool:
|
|
61
|
+
"""Whether session remote control is CURRENTLY exposed (pure predicate).
|
|
62
|
+
|
|
63
|
+
Exposed iff the bridge id is a truthy string AND the owning process is still
|
|
64
|
+
alive. This correctly collapses the three bridge states — key absent (None),
|
|
65
|
+
opened-then-closed (null/None, transient), and exposing (a `session_*`
|
|
66
|
+
string) — crossed with alive/dead. The single authority for "currently
|
|
67
|
+
exposed" (R3/AC3). No IO; inputs injected.
|
|
68
|
+
"""
|
|
69
|
+
return bool(bridge) and pid_alive
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _start_key(proc_start: str) -> int:
|
|
73
|
+
try:
|
|
74
|
+
return int(proc_start)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return -1
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def live_index(
|
|
80
|
+
session_procs: list[SessionProc],
|
|
81
|
+
agents_map: dict[str, int | None],
|
|
82
|
+
) -> dict[str, LiveInfo]:
|
|
83
|
+
"""PURE merge of registry session files + `claude agents --json`.
|
|
84
|
+
|
|
85
|
+
Groups `session_procs` by sessionId (resume keeps the sid, mints a new pid),
|
|
86
|
+
picks the injected proc-alive pid (newest `procStart` when several), and
|
|
87
|
+
marks liveness. Falls back to `agents_map` when there is no proc-confirmed
|
|
88
|
+
runtime — on non-Linux all `proc_alive` values are False, so a sid present in
|
|
89
|
+
`agents_map` is still reported alive (degraded liveness). No IO; inputs are
|
|
90
|
+
injected.
|
|
91
|
+
"""
|
|
92
|
+
index: dict[str, LiveInfo] = {}
|
|
93
|
+
|
|
94
|
+
by_sid: dict[str, list[SessionProc]] = {}
|
|
95
|
+
for sp in session_procs:
|
|
96
|
+
by_sid.setdefault(sp.sid, []).append(sp)
|
|
97
|
+
|
|
98
|
+
for sid, procs in by_sid.items():
|
|
99
|
+
alive_procs = [p for p in procs if p.proc_alive]
|
|
100
|
+
if alive_procs:
|
|
101
|
+
chosen = max(alive_procs, key=lambda p: _start_key(p.proc_start))
|
|
102
|
+
alive = True
|
|
103
|
+
# All alive pids, not just the newest — "current" must protect any
|
|
104
|
+
# ancestor pid of a resumed (multi-pid) sid.
|
|
105
|
+
pids = [p.pid for p in alive_procs]
|
|
106
|
+
else:
|
|
107
|
+
chosen = max(procs, key=lambda p: _start_key(p.proc_start))
|
|
108
|
+
alive = False
|
|
109
|
+
pids = []
|
|
110
|
+
index[sid] = LiveInfo(
|
|
111
|
+
sid=sid,
|
|
112
|
+
pid=chosen.pid if alive else None,
|
|
113
|
+
proc_start=chosen.proc_start,
|
|
114
|
+
status=chosen.status,
|
|
115
|
+
kind=chosen.kind,
|
|
116
|
+
entrypoint=chosen.entrypoint,
|
|
117
|
+
bridge=chosen.bridge,
|
|
118
|
+
source=_source_of(chosen.entrypoint, chosen.kind),
|
|
119
|
+
alive=alive,
|
|
120
|
+
proc_alive=alive,
|
|
121
|
+
pids=pids,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# `claude agents --json` is authoritative for liveness: it covers agent-only
|
|
125
|
+
# sids and rescues the degraded (no-/proc) path.
|
|
126
|
+
for sid, pid in agents_map.items():
|
|
127
|
+
if not sid:
|
|
128
|
+
continue
|
|
129
|
+
info = index.get(sid)
|
|
130
|
+
if info is None:
|
|
131
|
+
index[sid] = LiveInfo(
|
|
132
|
+
sid=sid, pid=pid, alive=True, pids=[pid] if pid else []
|
|
133
|
+
)
|
|
134
|
+
continue
|
|
135
|
+
info.alive = True
|
|
136
|
+
if info.pid is None:
|
|
137
|
+
info.pid = pid
|
|
138
|
+
if pid and pid not in info.pids:
|
|
139
|
+
info.pids.append(pid)
|
|
140
|
+
return index
|