cdx-manager 0.12.4 → 0.15.0

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.
@@ -75,12 +75,38 @@ def _is_pid_alive(pid):
75
75
  return False
76
76
 
77
77
 
78
+ def _completed_after(run, since):
79
+ """True when this run finished strictly after `since`.
80
+
81
+ `since` is timezone-aware (the CLI resolves it in local time); stored
82
+ timestamps are UTC with a `Z` suffix. A record with an unparseable or
83
+ missing `ended_at` is treated as not-yet-completed rather than matching, so
84
+ a corrupt row cannot be reported as freshly finished on every poll.
85
+ """
86
+ ended_at = run.get("ended_at")
87
+ if not ended_at:
88
+ return False
89
+ try:
90
+ ended = datetime.fromisoformat(str(ended_at).replace("Z", "+00:00"))
91
+ except ValueError:
92
+ return False
93
+ if ended.tzinfo is None:
94
+ ended = ended.replace(tzinfo=timezone.utc)
95
+ return ended > since
96
+
97
+
78
98
  def _refresh_stale_runs(data):
79
99
  now = utc_now_iso()
80
100
  changed = False
81
101
  for run in data["runs"]:
82
102
  if run.get("status") != "running":
83
103
  continue
104
+ if run.get("background_path") == "provider_native":
105
+ # The provider owns this agent's lifecycle and the pid it reports
106
+ # belongs to a daemon helper, not the run. Sweeping on pid liveness
107
+ # would mark a healthy delegated run stale; its terminal status
108
+ # comes from the provider instead.
109
+ continue
84
110
  pid = run.get("pid")
85
111
  if pid and _is_pid_alive(pid):
86
112
  continue
@@ -104,11 +130,31 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
104
130
  # the liveness proxy until finish() records the provider child's pid.
105
131
  "pid": os.getpid(),
106
132
  "started_at": utc_now_iso(),
133
+ # A failover run occupies several sessions in turn while staying one
134
+ # run. `session` and `provider` above name the current occupant so every
135
+ # existing reader keeps working; this is the ordered history behind
136
+ # them, and it exists from the start so no run is ever written in a
137
+ # shape the reporting cannot express.
138
+ "occupancies": [{
139
+ "session": session,
140
+ "provider": provider,
141
+ "started_at": utc_now_iso(),
142
+ "ended_at": None,
143
+ "reason": "initial_selection",
144
+ }],
107
145
  "ended_at": None,
108
146
  "duration_seconds": None,
109
147
  "exit_code": None,
110
148
  "usage": None,
111
149
  "artifacts": dict(artifacts or {}),
150
+ # Which mechanism is carrying a detached run, and the provider's own
151
+ # handle when it is the provider's. Present on every record so a reader
152
+ # never has to infer it from absence.
153
+ "background_path": None,
154
+ # Two handles, because the provider uses two: the session id names the
155
+ # transcript file, the short agent id is what `claude stop` accepts.
156
+ "provider_session_id": None,
157
+ "provider_agent_id": None,
112
158
  "error": None,
113
159
  "task_report": None,
114
160
  "final_payload": None,
@@ -128,6 +174,68 @@ class RunRegistry:
128
174
  _write_registry(self.path, data)
129
175
  return record
130
176
 
177
+ def set_pid(self, run_id, pid):
178
+ """Point a registered run at the process actually doing the work.
179
+
180
+ `start()` records the caller's own pid, which is right when that caller
181
+ stays to supervise. A detached launch does not: it returns immediately,
182
+ so its pid dies within seconds and `_refresh_stale_runs` would find no
183
+ live process, mark the run `stale`, and stamp an `ended_at` — reporting
184
+ a run that is still going as finished, including to `list(since=...)`.
185
+ """
186
+ with _registry_lock(self.path):
187
+ data = _read_registry(self.path)
188
+ for run in data["runs"]:
189
+ if run.get("run_id") == run_id:
190
+ run["pid"] = pid
191
+ _write_registry(self.path, data)
192
+ return run
193
+ return None
194
+
195
+ def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
196
+ with _registry_lock(self.path):
197
+ data = _read_registry(self.path)
198
+ for run in data["runs"]:
199
+ if run.get("run_id") != run_id:
200
+ continue
201
+ run["background_path"] = path
202
+ run["provider_session_id"] = provider_session_id
203
+ run["provider_agent_id"] = provider_agent_id
204
+ _write_registry(self.path, data)
205
+ return run
206
+ return None
207
+
208
+ def migrate(self, run_id, *, session, provider, reason):
209
+ """Move a still-running run onto another session, keeping one run_id.
210
+
211
+ Closes the current occupancy with the reason it ended and opens the
212
+ next one. The top-level `session`/`provider` follow the new occupant so
213
+ readers that know nothing about occupancies stay correct.
214
+ """
215
+ with _registry_lock(self.path):
216
+ data = _read_registry(self.path)
217
+ now = utc_now_iso()
218
+ for run in data["runs"]:
219
+ if run.get("run_id") != run_id:
220
+ continue
221
+ occupancies = run.get("occupancies") or []
222
+ if occupancies and occupancies[-1].get("ended_at") is None:
223
+ occupancies[-1]["ended_at"] = now
224
+ occupancies[-1]["reason"] = reason
225
+ occupancies.append({
226
+ "session": session,
227
+ "provider": provider,
228
+ "started_at": now,
229
+ "ended_at": None,
230
+ "reason": "failover_target",
231
+ })
232
+ run["occupancies"] = occupancies
233
+ run["session"] = session
234
+ run["provider"] = provider
235
+ _write_registry(self.path, data)
236
+ return run
237
+ return None
238
+
131
239
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
132
240
  with _registry_lock(self.path):
133
241
  return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
@@ -140,6 +248,10 @@ class RunRegistry:
140
248
  continue
141
249
  run["status"] = status
142
250
  run["ended_at"] = now
251
+ occupancies = run.get("occupancies") or []
252
+ if occupancies and occupancies[-1].get("ended_at") is None:
253
+ occupancies[-1]["ended_at"] = now
254
+ occupancies[-1]["reason"] = status
143
255
  if run.get("started_at"):
144
256
  try:
145
257
  start = datetime.fromisoformat(str(run["started_at"]).replace("Z", "+00:00"))
@@ -167,13 +279,26 @@ class RunRegistry:
167
279
  return run
168
280
  return None
169
281
 
170
- def list(self, limit=20):
282
+ def list(self, limit=20, since=None):
283
+ """Recent runs, newest first.
284
+
285
+ `since` selects every run that *completed* strictly after that moment
286
+ and bypasses `limit` entirely. A cursor caller is asking "what finished
287
+ since I last looked"; capping that by row count would hand back a
288
+ truncated answer that looks complete, which is the silent miss the
289
+ cursor exists to remove. Runs still in flight have no completion time
290
+ and are excluded — they will be returned by a later call, once they
291
+ have actually completed.
292
+ """
171
293
  with _registry_lock(self.path):
172
294
  data = _read_registry(self.path)
173
295
  changed = _refresh_stale_runs(data)
174
296
  if changed:
175
297
  _write_registry(self.path, data)
176
- return data["runs"][: max(0, int(limit or 20))]
298
+ runs = data["runs"]
299
+ if since is not None:
300
+ return [run for run in runs if _completed_after(run, since)]
301
+ return runs[: max(0, int(limit or 20))]
177
302
 
178
303
  def get(self, run_id):
179
304
  with _registry_lock(self.path):
@@ -0,0 +1,343 @@
1
+ """Backup domain: exporting and importing session bundles.
2
+
3
+ Split out of session_service.py along the second of the two seams the req_027
4
+ coupling measurement supports. Moved verbatim; re-exported by session_service.
5
+ """
6
+
7
+ import base64
8
+ import binascii
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+
13
+ from .backup_bundle import decode_bundle, encode_bundle
14
+ from .config import PROVIDER_CLAUDE
15
+ from .errors import CdxError
16
+ from .fs_utils import atomic_write, remove_tree
17
+ from .session_helpers import (
18
+ _encode,
19
+ _ensure_private_dir,
20
+ _get_session_auth_home,
21
+ _get_session_root,
22
+ _local_now_iso,
23
+ _normalize_provider,
24
+ _validate_new_session_name,
25
+ list_sessions,
26
+ )
27
+
28
+ _FORCE_IMPORT_PRESERVED_PROFILE_PATHS = ("plugins",)
29
+
30
+ def _safe_relpath(path):
31
+ normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
32
+ if (
33
+ not normalized
34
+ or normalized == "."
35
+ or normalized.startswith("/")
36
+ or (len(normalized) > 1 and normalized[1] == ":")
37
+ or normalized == ".."
38
+ or normalized.startswith("../")
39
+ ):
40
+ raise CdxError("Bundle contains an unsafe file path.")
41
+ return normalized
42
+
43
+ def _build_export_session_record(session):
44
+ return {
45
+ "name": session["name"],
46
+ "provider": session["provider"],
47
+ "label": session.get("label"),
48
+ "enabled": session.get("enabled", True) is not False,
49
+ "createdAt": session.get("createdAt"),
50
+ "updatedAt": session.get("updatedAt"),
51
+ "lastLaunchedAt": session.get("lastLaunchedAt"),
52
+ "lastStatusAt": session.get("lastStatusAt"),
53
+ "lastStatus": session.get("lastStatus"),
54
+ "launch": session.get("launch"),
55
+ "auth": session.get("auth"),
56
+ }
57
+
58
+ def _auth_bundle_paths(provider):
59
+ if provider == PROVIDER_CLAUDE:
60
+ return [
61
+ "claude-home/configs/default.json",
62
+ "claude-home/credentials/default.json",
63
+ "claude-home/.claude/.credentials.json",
64
+ "claude-home/.claude.json",
65
+ "claude-home/auth.json",
66
+ ]
67
+ return [
68
+ "auth.json",
69
+ ]
70
+
71
+ def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
72
+ files = []
73
+ total_bytes = 0
74
+ if not os.path.isdir(session_root):
75
+ return {"files": files, "file_count": 0, "bytes": 0}
76
+ for rel_path in _auth_bundle_paths(provider):
77
+ full_path = os.path.join(session_root, rel_path)
78
+ if not os.path.isfile(full_path):
79
+ continue
80
+ with open(full_path, "rb") as handle:
81
+ raw_content = handle.read()
82
+ total_bytes += len(raw_content)
83
+ content = base64.b64encode(raw_content).decode("ascii")
84
+ files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
85
+ if progress_callback:
86
+ progress_callback({
87
+ "event": "profile_progress",
88
+ "session_name": session_name,
89
+ "file_count": len(files),
90
+ "bytes": total_bytes,
91
+ })
92
+ return {"files": files, "file_count": len(files), "bytes": total_bytes}
93
+
94
+ def _validate_import_profile_files(payload, imported_sessions):
95
+ profiles = payload.get("profiles") or {}
96
+ if not isinstance(profiles, dict):
97
+ raise CdxError("Bundle contains invalid profile data.")
98
+ decoded_profiles = {}
99
+ for session_payload in imported_sessions:
100
+ name = session_payload["name"]
101
+ files = []
102
+ profile_items = profiles.get(name, [])
103
+ if not isinstance(profile_items, list):
104
+ raise CdxError(f"Bundle contains invalid profile data for session {name}.")
105
+ for item in profile_items:
106
+ if not isinstance(item, dict):
107
+ raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
108
+ rel_path = _safe_relpath(item.get("path"))
109
+ data_b64 = item.get("data_b64")
110
+ if not isinstance(data_b64, str):
111
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
112
+ try:
113
+ content = base64.b64decode(data_b64.encode("ascii"), validate=True)
114
+ except (binascii.Error, UnicodeEncodeError) as error:
115
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
116
+ files.append({"path": rel_path, "content": content})
117
+ decoded_profiles[name] = files
118
+ return decoded_profiles
119
+
120
+ def _force_import_preserved_profile_paths(session_root):
121
+ return [rel_path for rel_path in _FORCE_IMPORT_PRESERVED_PROFILE_PATHS if os.path.exists(os.path.join(session_root, rel_path))]
122
+
123
+ def _restore_force_import_profile_paths(name, backup_root, session_root, rel_paths):
124
+ if not backup_root:
125
+ return
126
+ for rel_path in rel_paths:
127
+ source_path = os.path.join(backup_root, rel_path)
128
+ dest_path = os.path.join(session_root, rel_path)
129
+ if not os.path.exists(source_path) or os.path.exists(dest_path):
130
+ continue
131
+ try:
132
+ if os.path.isdir(source_path) and not os.path.islink(source_path):
133
+ shutil.copytree(source_path, dest_path)
134
+ else:
135
+ _ensure_private_dir(os.path.dirname(dest_path))
136
+ shutil.copy2(source_path, dest_path)
137
+ except Exception as error:
138
+ raise CdxError(f"Could not restore local plugin state for session {name}: {rel_path}") from error
139
+
140
+ def _resolve_session_subset(store, session_names):
141
+ if not session_names:
142
+ return list_sessions(store)
143
+ by_name = {session["name"]: session for session in list_sessions(store)}
144
+ selected = []
145
+ for name in session_names:
146
+ session = by_name.get(name)
147
+ if not session:
148
+ raise CdxError(f"Unknown session: {name}")
149
+ selected.append(session)
150
+ return selected
151
+
152
+ def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
153
+ if os.path.exists(session_root):
154
+ remove_tree(session_root, ignore_errors=True)
155
+ if backup_root and os.path.exists(backup_root):
156
+ os.rename(backup_root, session_root)
157
+ if old_record:
158
+ store["replace_session"](name, old_record)
159
+ if old_state is not None:
160
+ store["write_session_state"](name, old_state)
161
+ else:
162
+ store["remove_session"](name)
163
+
164
+ def export_bundle(base_dir, store, file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
165
+ if not file_path:
166
+ raise CdxError("Export path is required.")
167
+ if os.path.exists(file_path) and not force:
168
+ raise CdxError(f"Export path already exists: {file_path}")
169
+
170
+ sessions = _resolve_session_subset(store, session_names)
171
+ payload = {
172
+ "schema_version": 1,
173
+ "created_at": _local_now_iso(),
174
+ "include_auth": bool(include_auth),
175
+ "sessions": [],
176
+ "states": {},
177
+ "profiles": {},
178
+ }
179
+ profile_file_count = 0
180
+ profile_bytes = 0
181
+ if progress_callback:
182
+ progress_callback({
183
+ "event": "export_started",
184
+ "include_auth": bool(include_auth),
185
+ "session_count": len(sessions),
186
+ "session_names": [session["name"] for session in sessions],
187
+ })
188
+ for session in sessions:
189
+ if progress_callback:
190
+ progress_callback({"event": "session_started", "session_name": session["name"]})
191
+ payload["sessions"].append(_build_export_session_record(session))
192
+ state = store["read_session_state"](session["name"])
193
+ if state is not None:
194
+ payload["states"][session["name"]] = state
195
+ if include_auth:
196
+ session_root = session.get("sessionRoot") or _get_session_root(base_dir, session["name"])
197
+ profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
198
+ payload["profiles"][session["name"]] = profile["files"]
199
+ profile_file_count += profile["file_count"]
200
+ profile_bytes += profile["bytes"]
201
+ if progress_callback:
202
+ progress_callback({"event": "session_finished", "session_name": session["name"]})
203
+
204
+ if progress_callback:
205
+ progress_callback({"event": "encoding_started"})
206
+ bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
207
+ if progress_callback:
208
+ progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
209
+ # Atomic + 0o600 from the start: the export path may live in a
210
+ # world-traversable directory, and a crash must not clobber a
211
+ # previous good bundle with a truncated one.
212
+ atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
213
+ return {
214
+ "path": file_path,
215
+ "include_auth": include_auth,
216
+ "session_names": [session["name"] for session in sessions],
217
+ "session_count": len(sessions),
218
+ "profile_file_count": profile_file_count if include_auth else None,
219
+ "profile_bytes": profile_bytes if include_auth else None,
220
+ "bundle_size_bytes": len(bundle_bytes),
221
+ }
222
+
223
+ def import_bundle(base_dir, store, file_path,
224
+ passphrase=None,
225
+ session_names=None,
226
+ force=False,
227
+ merge=False,
228
+ allow_authless_force=False,):
229
+ if not file_path or not os.path.isfile(file_path):
230
+ raise CdxError(f"Bundle file not found: {file_path}")
231
+ with open(file_path, "rb") as handle:
232
+ decoded = decode_bundle(handle.read(), passphrase=passphrase)
233
+ payload = decoded["payload"]
234
+ imported_sessions = payload.get("sessions") or []
235
+ if payload.get("schema_version") != 1:
236
+ raise CdxError("Unsupported bundle payload schema version.")
237
+
238
+ selected_names = set(session_names or [])
239
+ if selected_names:
240
+ imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
241
+ missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
242
+ if missing_names:
243
+ raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
244
+ names = [item["name"] for item in imported_sessions]
245
+
246
+ existing = {session["name"] for session in list_sessions(store)}
247
+ conflicts = [name for name in names if name in existing]
248
+ if conflicts and not force and not merge:
249
+ raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
250
+ if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
251
+ raise CdxError(
252
+ "Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
253
+ "Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
254
+ )
255
+
256
+ for session_payload in imported_sessions:
257
+ _validate_new_session_name(session_payload["name"])
258
+ _normalize_provider(session_payload["provider"])
259
+ decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
260
+
261
+ for session_payload in imported_sessions:
262
+ name = session_payload["name"]
263
+ provider = _normalize_provider(session_payload["provider"])
264
+ is_existing = name in existing
265
+
266
+ session_root = _get_session_root(base_dir, name)
267
+ auth_home = _get_session_auth_home(base_dir, name, provider)
268
+ _ensure_private_dir(base_dir)
269
+ _ensure_private_dir(os.path.join(base_dir, "profiles"))
270
+ backup_root = None
271
+ old_record = None
272
+ old_state = None
273
+ preserved_profile_paths = []
274
+ if is_existing and force:
275
+ old_record = store["get_session"](name)
276
+ old_state = store["read_session_state"](name)
277
+ preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
278
+ if os.path.exists(session_root):
279
+ backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
280
+ os.rmdir(backup_root)
281
+ os.rename(session_root, backup_root)
282
+ is_existing = False
283
+ _ensure_private_dir(session_root)
284
+ _ensure_private_dir(auth_home)
285
+
286
+ existing_state_before = None
287
+ try:
288
+ if is_existing and merge:
289
+ existing_record = store["get_session"](name) or {}
290
+ # replace_session resets the state file to defaults, so the
291
+ # local state must be captured before it runs.
292
+ existing_state_before = store["read_session_state"](name) or {}
293
+ bundle_record = {
294
+ **session_payload,
295
+ "provider": provider,
296
+ "enabled": session_payload.get("enabled", True) is not False,
297
+ "sessionRoot": session_root,
298
+ "authHome": auth_home,
299
+ }
300
+ # Existing values take precedence; bundle fills in missing keys only.
301
+ merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
302
+ merged_record["sessionRoot"] = session_root
303
+ merged_record["authHome"] = auth_home
304
+ store["replace_session"](name, merged_record)
305
+ else:
306
+ session_record = {
307
+ **session_payload,
308
+ "provider": provider,
309
+ "enabled": session_payload.get("enabled", True) is not False,
310
+ "sessionRoot": session_root,
311
+ "authHome": auth_home,
312
+ }
313
+ store["replace_session"](name, session_record)
314
+
315
+ state = (payload.get("states") or {}).get(name)
316
+ if is_existing and merge:
317
+ merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
318
+ if merged_state:
319
+ store["write_session_state"](name, merged_state)
320
+ elif state is not None:
321
+ store["write_session_state"](name, state)
322
+
323
+ for item in decoded_profiles.get(name, []):
324
+ dest_path = os.path.join(session_root, item["path"])
325
+ # In merge mode, skip files that already exist locally.
326
+ if is_existing and merge and os.path.exists(dest_path):
327
+ continue
328
+ _ensure_private_dir(os.path.dirname(dest_path))
329
+ # Decrypted credentials: 0o600 from creation, no umask window.
330
+ atomic_write(dest_path, item["content"], mode=0o600)
331
+ _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
332
+ except Exception:
333
+ _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
334
+ raise
335
+ finally:
336
+ if backup_root and os.path.exists(backup_root):
337
+ remove_tree(backup_root, ignore_errors=True)
338
+
339
+ return {
340
+ "path": file_path,
341
+ "session_names": names,
342
+ "include_auth": bool(decoded["meta"].get("include_auth")),
343
+ }