cortexdb-cli 0.5.3__tar.gz → 0.6.0__tar.gz

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.
Files changed (41) hide show
  1. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/PKG-INFO +1 -1
  2. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/__init__.py +1 -1
  3. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/client.py +8 -0
  4. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/__init__.py +1 -1
  5. cortexdb_cli-0.6.0/cortexdb_cli/commands/admin.py +612 -0
  6. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/auth.py +38 -5
  7. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/code.py +60 -4
  8. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/config_cmd.py +50 -50
  9. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/errors.py +96 -96
  10. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/pyproject.toml +1 -1
  11. cortexdb_cli-0.5.3/.gitignore +0 -101
  12. cortexdb_cli-0.5.3/cortexdb_cli/commands/admin.py +0 -83
  13. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/README.md +0 -0
  14. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/answer.py +0 -0
  15. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/audit.py +0 -0
  16. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/beliefs.py +0 -0
  17. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/claims.py +0 -0
  18. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/compose.py +0 -0
  19. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/conflicts.py +0 -0
  20. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/entities.py +0 -0
  21. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/episodes.py +0 -0
  22. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/experience.py +0 -0
  23. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/export_cmd.py +0 -0
  24. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/facts.py +0 -0
  25. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/forget.py +0 -0
  26. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/import_cmd.py +0 -0
  27. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/init.py +0 -0
  28. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/interactive.py +0 -0
  29. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/onboard.py +0 -0
  30. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/policy.py +0 -0
  31. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/recall.py +0 -0
  32. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/scopes.py +0 -0
  33. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/search.py +0 -0
  34. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/understanding.py +0 -0
  35. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/commands/wire.py +0 -0
  36. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/config.py +0 -0
  37. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/harness_packs_data.py +0 -0
  38. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/main.py +0 -0
  39. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/onboarding.py +0 -0
  40. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/output.py +0 -0
  41. {cortexdb_cli-0.5.3 → cortexdb_cli-0.6.0}/cortexdb_cli/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexdb-cli
3
- Version: 0.5.3
3
+ Version: 0.6.0
4
4
  Summary: Command-line interface for CortexDB — the long-term memory layer for AI.
5
5
  Project-URL: Homepage, https://cortexdb.ai
6
6
  Project-URL: Documentation, https://cortexdb.ai/docs
@@ -1,3 +1,3 @@
1
1
  """CortexDB CLI - command-line interface for the v1 memory API."""
2
2
 
3
- __version__ = "0.5.3"
3
+ __version__ = "0.6.0"
@@ -63,6 +63,14 @@ class CortexClient:
63
63
  ) -> httpx.Response:
64
64
  return self._http.post(path, json=json, params=params)
65
65
 
66
+ def put(
67
+ self,
68
+ path: str,
69
+ json: dict[str, Any] | None = None,
70
+ params: dict[str, Any] | None = None,
71
+ ) -> httpx.Response:
72
+ return self._http.put(path, json=json, params=params)
73
+
66
74
  def delete(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
67
75
  return self._http.delete(path, params=params)
68
76
 
@@ -1 +1 @@
1
- """CortexDB CLI command modules."""
1
+ """CortexDB CLI command modules."""
@@ -0,0 +1,612 @@
1
+ """cortexdb admin - health, usage, layer stats, retention, unified WAL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from cortexdb_cli.client import get_client
10
+ from cortexdb_cli.errors import handle_errors
11
+ from cortexdb_cli.output import (
12
+ console,
13
+ escape,
14
+ is_json_mode,
15
+ print_health,
16
+ print_json,
17
+ print_ontology,
18
+ print_success,
19
+ print_usage,
20
+ spinner,
21
+ )
22
+
23
+
24
+ @click.group("admin")
25
+ def admin_group() -> None:
26
+ """Diagnostics - reachability, identity, rate-limit, layer status."""
27
+
28
+
29
+ @admin_group.command("health")
30
+ @click.pass_context
31
+ @handle_errors
32
+ def admin_health(ctx: click.Context) -> None:
33
+ """Auth + reachability check (GET /v1/auth/whoami)."""
34
+ cfg = ctx.obj
35
+ with get_client(cfg) as client:
36
+ resp = client.get("/v1/auth/whoami")
37
+ resp.raise_for_status()
38
+ body = resp.json()
39
+ # Normalise into the shape print_health expects.
40
+ body.setdefault("status", "ok")
41
+ print_health(body, cfg)
42
+
43
+
44
+ @admin_group.command("usage")
45
+ @click.pass_context
46
+ @handle_errors
47
+ def admin_usage(ctx: click.Context) -> None:
48
+ """Tier, capabilities, rate-limit headroom, token expiry."""
49
+ cfg = ctx.obj
50
+ with get_client(cfg) as client:
51
+ if not is_json_mode(cfg):
52
+ with spinner("Loading usage..."):
53
+ resp = client.get("/v1/auth/whoami")
54
+ else:
55
+ resp = client.get("/v1/auth/whoami")
56
+ resp.raise_for_status()
57
+ body = resp.json()
58
+ # Fold rate-limit + expiry response headers into the body so the
59
+ # output formatter can render them in one block.
60
+ for k in (
61
+ "X-RateLimit-Limit",
62
+ "X-RateLimit-Reset",
63
+ "X-RateLimit-Remaining",
64
+ "X-Cortex-Token-Expires-In",
65
+ "X-Cortex-Token-Expires-At",
66
+ ):
67
+ if k in resp.headers:
68
+ body[k] = resp.headers[k]
69
+ print_usage(body, cfg)
70
+
71
+
72
+ @admin_group.command("layers")
73
+ @click.pass_context
74
+ @handle_errors
75
+ def admin_layers(ctx: click.Context) -> None:
76
+ """Per-layer counts and synthesis lag (GET /v1/admin/layers/stats).
77
+
78
+ Stability: experimental - the endpoint is gated by the
79
+ `admin.layers.stats` capability and may not be present on the free tier.
80
+ """
81
+ cfg = ctx.obj
82
+ with get_client(cfg) as client:
83
+ if not is_json_mode(cfg):
84
+ with spinner("Loading layer stats..."):
85
+ resp = client.get("/v1/admin/layers/stats")
86
+ else:
87
+ resp = client.get("/v1/admin/layers/stats")
88
+ resp.raise_for_status()
89
+ print_ontology(resp.json(), cfg)
90
+
91
+
92
+ # -- retention (unified WAL) -------------------------------------------------
93
+ #
94
+ # The operator surface over the D1 retention plane. Every write here is
95
+ # irreversible in effect - an applied sweep ERASES expired captured events
96
+ # through the ordinary erasure plane - so reporting is the default on both
97
+ # mutating commands and `--apply` is the only thing that sends
98
+ # `dry_run: false`.
99
+
100
+
101
+ def _parse_source_days(pairs: tuple[str, ...]) -> dict[str, int]:
102
+ """Parse repeated ``--source-days KEY=N`` options into the wire map.
103
+
104
+ Only the option's own grammar is checked here (KEY=VALUE, N a positive
105
+ integer). The ``source:`` prefix rule and the 1..=36500 ceiling belong to
106
+ the server's governance validator, which answers 422
107
+ INVALID_RETENTION_POLICY - duplicating them here would let the CLI and
108
+ the server disagree about what a valid rule is.
109
+ """
110
+ out: dict[str, int] = {}
111
+ for pair in pairs:
112
+ if "=" not in pair:
113
+ raise click.UsageError(f"--source-days must be KEY=DAYS, got {pair!r}")
114
+ key, raw = pair.split("=", 1)
115
+ key = key.strip()
116
+ if not key:
117
+ raise click.UsageError(f"--source-days needs a key, got {pair!r}")
118
+ try:
119
+ days = int(raw)
120
+ except ValueError:
121
+ raise click.UsageError(
122
+ f"--source-days value must be a whole number of days, got {raw!r}"
123
+ ) from None
124
+ if days < 1:
125
+ raise click.UsageError(
126
+ f"--source-days must be at least 1 day, got {days} for {key!r}"
127
+ )
128
+ if key in out:
129
+ raise click.UsageError(f"--source-days {key!r} given twice")
130
+ out[key] = days
131
+ return out
132
+
133
+
134
+ def _print_retention_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
135
+ """Render GET /v1/admin/retention."""
136
+ if is_json_mode(cfg):
137
+ print_json(data)
138
+ return
139
+
140
+ from rich.table import Table
141
+
142
+ config = Table(title="[header]Retention[/header]", border_style="cyan")
143
+ config.add_column("Setting", style="bold")
144
+ config.add_column("Value")
145
+ interval = data.get("sweep_interval_secs", 0)
146
+ if data.get("enabled"):
147
+ scheduled = "yes"
148
+ elif data.get("scheduler_disabled"):
149
+ scheduled = "[warning]no (CORTEX_SCHEDULER_DISABLE)[/warning]"
150
+ else:
151
+ scheduled = "no (operator-triggered only)"
152
+ config.add_row("scheduled", scheduled)
153
+ config.add_row("sweep interval", f"{interval}s" if interval else "-")
154
+ default_days = data.get("deployment_default_days")
155
+ config.add_row("deployment default", f"{default_days}d" if default_days else "keep")
156
+ config.add_row("max events/tick", str(data.get("max_events_per_tick", "")))
157
+ config.add_row("tick budget", str(data.get("tick_budget_secs", "")) + "s")
158
+ config.add_row(
159
+ "compact after sweep", "yes" if data.get("compact_after_sweep") else "no"
160
+ )
161
+ config.add_row("cursor", str(data.get("cursor", "")))
162
+ config.add_row("watermark", str(data.get("watermark", "")))
163
+ config.add_row(
164
+ "cursor trustworthy",
165
+ "yes" if data.get("fingerprint_ok") else "no (next applied tick rescans)",
166
+ )
167
+ config.add_row("running", "yes" if data.get("running") else "no")
168
+ if data.get("native_profile_blocked"):
169
+ config.add_row("native profile", "[warning]retention refused[/warning]")
170
+ console.print(config)
171
+
172
+ policies = data.get("policies") or []
173
+ if policies:
174
+ table = Table(title="[header]Policies[/header]", border_style="cyan")
175
+ table.add_column("Scope", style="bold")
176
+ table.add_column("Default", justify="right")
177
+ table.add_column("Per-source", style="dim")
178
+ for entry in policies:
179
+ policy = entry.get("policy") or {}
180
+ days = policy.get("default_retention_days")
181
+ per_source = policy.get("per_source_days") or {}
182
+ table.add_row(
183
+ escape(str(entry.get("scope", ""))),
184
+ f"{days}d" if days else "keep",
185
+ escape(", ".join(f"{k}={v}d" for k, v in sorted(per_source.items()))),
186
+ )
187
+ console.print(table)
188
+ else:
189
+ console.print("[dim]No per-scope retention rules stored.[/dim]")
190
+
191
+ last = data.get("last")
192
+ if isinstance(last, dict):
193
+ note = " [warning](dry run)[/warning]" if last.get("dry_run") else ""
194
+ console.print(
195
+ "[dim]Last tick:[/dim] "
196
+ f"scanned {last.get('scanned', 0)}, "
197
+ f"expired {last.get('expired', 0)}, "
198
+ f"erased {last.get('erased', 0)}, "
199
+ f"stopped {escape(str(last.get('stopped', '')))}" + note
200
+ )
201
+
202
+
203
+ def _print_sweep_report(data: dict[str, Any], cfg: dict[str, Any]) -> None:
204
+ """Render POST /v1/admin/retention/sweep (or its 202 still-running body)."""
205
+ if is_json_mode(cfg):
206
+ print_json(data)
207
+ return
208
+
209
+ from rich.panel import Panel
210
+
211
+ if "erased" not in data:
212
+ # 202: the tick outlived the blocking-lane deadline and is still going.
213
+ poll = escape(str(data.get("poll", "/v1/admin/retention")))
214
+ console.print(
215
+ Panel(
216
+ f"Sweep still running; poll [bold]{poll}[/bold]",
217
+ title="[header]Retention sweep[/header]",
218
+ border_style="yellow",
219
+ )
220
+ )
221
+ return
222
+
223
+ dry = data.get("dry_run", True)
224
+ lines = [
225
+ f"scanned [bold]{data.get('scanned', 0)}[/bold] captured row(s)",
226
+ f"expired [bold]{data.get('expired', 0)}[/bold], "
227
+ f"erased [bold]{data.get('erased', 0)}[/bold], "
228
+ f"kept forever [bold]{data.get('kept_forever', 0)}[/bold]",
229
+ f"cursor {data.get('cursor_before', 0)} -> {data.get('cursor_after', 0)} "
230
+ f"(watermark {data.get('watermark', 0)})",
231
+ f"stopped: {escape(str(data.get('stopped', '')))} "
232
+ f"in {data.get('elapsed_ms', 0)}ms",
233
+ ]
234
+ held = data.get("held") or {}
235
+ if isinstance(held, dict) and any(held.values()):
236
+ lines.append(f"[dim]held: {escape(str(held))}[/dim]")
237
+ for error in data.get("errors") or []:
238
+ lines.append(f"[error]{escape(str(error))}[/error]")
239
+ title = "[header]Retention sweep" + (" (dry run)" if dry else " (APPLIED)")
240
+ console.print(
241
+ Panel(
242
+ "\n".join(lines),
243
+ title=title + "[/header]",
244
+ border_style="yellow" if dry else "red",
245
+ )
246
+ )
247
+
248
+
249
+ def _print_wal_status(data: dict[str, Any], cfg: dict[str, Any]) -> None:
250
+ """Render GET /v1/admin/wal."""
251
+ if is_json_mode(cfg):
252
+ print_json(data)
253
+ return
254
+
255
+ from rich.table import Table
256
+
257
+ table = Table(title="[header]Unified WAL[/header]", border_style="cyan")
258
+ table.add_column("Field", style="bold")
259
+ table.add_column("Value", justify="right")
260
+ for label, key in (
261
+ ("live rows", "live_count"),
262
+ ("next offset", "next_offset"),
263
+ ("head offset", "head_offset"),
264
+ ("captured head", "captured_head_offset"),
265
+ ("partitions", "partitions"),
266
+ ("retired markers", "retired_marker_count"),
267
+ ):
268
+ value = data.get(key)
269
+ table.add_row(label, "-" if value is None else str(value))
270
+ if data.get("native_profile_blocked"):
271
+ table.add_row("native profile", "[warning]mutations refused[/warning]")
272
+ console.print(table)
273
+
274
+ consumers = data.get("consumer_offsets") or {}
275
+ if consumers:
276
+ floors = Table(title="[header]Consumer floors[/header]", border_style="cyan")
277
+ floors.add_column("Consumer", style="bold")
278
+ floors.add_column("Offset", justify="right")
279
+ for name, offset in sorted(consumers.items()):
280
+ floors.add_row(escape(str(name)), str(offset))
281
+ console.print(floors)
282
+
283
+ verify = data.get("verify")
284
+ if isinstance(verify, dict):
285
+ ok = verify.get("ok")
286
+ if ok is None:
287
+ # Not a verdict: the self-check is bounded and a production-sized
288
+ # log is past that bound, so nothing was checked. Rendering this
289
+ # as a failure is how a healthy store gets restored from backup.
290
+ skipped = escape(
291
+ str(verify.get("skipped", "log exceeds the bounded self-check"))
292
+ )
293
+ console.print(f"[warning]Integrity check not run:[/warning] {skipped}")
294
+ elif ok:
295
+ console.print("[success]Integrity check passed.[/success]")
296
+ else:
297
+ reason = escape(str(verify.get("reason", "unknown")))
298
+ console.print(f"[error]Integrity check failed:[/error] {reason}")
299
+
300
+
301
+ def _print_compaction(data: dict[str, Any], cfg: dict[str, Any]) -> None:
302
+ """Render POST /v1/admin/wal/compact (or its 202 still-running body)."""
303
+ if is_json_mode(cfg):
304
+ print_json(data)
305
+ return
306
+
307
+ from rich.panel import Panel
308
+
309
+ if "removed_tombstones" not in data:
310
+ poll = escape(str(data.get("poll", "/v1/admin/wal")))
311
+ console.print(
312
+ Panel(
313
+ f"Compaction still running; poll [bold]{poll}[/bold]",
314
+ title="[header]WAL compaction[/header]",
315
+ border_style="yellow",
316
+ )
317
+ )
318
+ return
319
+
320
+ dry = data.get("dry_run", True)
321
+ lines = [
322
+ f"scanned [bold]{data.get('scanned', 0)}[/bold] row(s)",
323
+ f"removed {data.get('removed_tombstones', 0)} tombstone(s), "
324
+ f"{data.get('removed_orphaned_twins', 0)} orphaned twin(s)",
325
+ f"skipped {data.get('skipped_in_use', 0)} in use, "
326
+ f"{data.get('skipped_above_bound', 0)} above the bound",
327
+ f"live rows {data.get('live_count_before', 0)} -> "
328
+ f"{data.get('live_count_after', 0)}, "
329
+ f"{data.get('bytes_removed', 0)} byte(s) reclaimed",
330
+ f"ranges compacted: {data.get('ranges_compacted', 0)} "
331
+ f"in {data.get('elapsed_ms', 0)}ms",
332
+ ]
333
+ if data.get("truncated_by_budget"):
334
+ lines.append("[warning]Budget reached - rerun to continue.[/warning]")
335
+ title = "[header]WAL compaction" + (" (dry run)" if dry else " (APPLIED)")
336
+ console.print(
337
+ Panel(
338
+ "\n".join(lines),
339
+ title=title + "[/header]",
340
+ border_style="yellow" if dry else "red",
341
+ )
342
+ )
343
+
344
+
345
+ @admin_group.group("retention")
346
+ def retention_group() -> None:
347
+ """Unified-WAL retention - per-scope rules and the expiry sweep.
348
+
349
+ Requires trusted-operator access. Reporting needs `diagnostics.read`;
350
+ storing a rule or applying a sweep needs `admin.compact`.
351
+ """
352
+
353
+
354
+ @retention_group.command("show")
355
+ @click.pass_context
356
+ @handle_errors
357
+ def retention_show(ctx: click.Context) -> None:
358
+ """Config, stored rules, cursor and last tick (GET /v1/admin/retention).
359
+
360
+ 503 NOT_AVAILABLE when this process runs no retention sweeper (legacy
361
+ WAL, no coordinator, or a configuration the sweeper refused).
362
+ """
363
+ cfg = ctx.obj
364
+ with get_client(cfg) as client:
365
+ if not is_json_mode(cfg):
366
+ with spinner("Loading retention status..."):
367
+ resp = client.get("/v1/admin/retention")
368
+ else:
369
+ resp = client.get("/v1/admin/retention")
370
+ resp.raise_for_status()
371
+ _print_retention_status(resp.json(), cfg)
372
+
373
+
374
+ @retention_group.command("set")
375
+ @click.option("--scope", "-S", required=True, help="Scope path the rule is keyed on.")
376
+ @click.option(
377
+ "--days",
378
+ type=click.IntRange(min=1),
379
+ default=None,
380
+ help="Expire captured events after this many days. Omit for an explicit KEEP.",
381
+ )
382
+ @click.option(
383
+ "--source-days",
384
+ "source_days",
385
+ multiple=True,
386
+ metavar="KEY=DAYS",
387
+ help="Per-source override, e.g. source:slack=30. Repeatable.",
388
+ )
389
+ @click.pass_context
390
+ @handle_errors
391
+ def retention_set(
392
+ ctx: click.Context,
393
+ scope: str,
394
+ days: int | None,
395
+ source_days: tuple[str, ...],
396
+ ) -> None:
397
+ """Store the retention rule for one scope (PUT /v1/admin/retention/policies).
398
+
399
+ Irreversible in effect: an applied sweep under this rule ERASES expired
400
+ events from the scope and every scope beneath it. Omitting `--days`
401
+ stores an explicit KEEP, which overrides the deployment default for the
402
+ subtree but never a shorter rule inherited from an ancestor scope.
403
+ Storing a rule always resets the durable sweep cursor.
404
+ """
405
+ cfg = ctx.obj
406
+ body: dict[str, Any] = {"scope": scope, "default_retention_days": days}
407
+ per_source = _parse_source_days(source_days)
408
+ if per_source:
409
+ body["per_source_days"] = per_source
410
+ with get_client(cfg) as client:
411
+ resp = client.put("/v1/admin/retention/policies", json=body)
412
+ resp.raise_for_status()
413
+ data = resp.json()
414
+ if is_json_mode(cfg):
415
+ print_json(data)
416
+ return
417
+ policy = data.get("policy") or {}
418
+ stored = policy.get("default_retention_days")
419
+ reset = " (sweep cursor reset)" if data.get("cursor_reset") else ""
420
+ print_success(
421
+ f"Retention rule stored for {scope}: "
422
+ + (f"{stored} day(s)" if stored else "keep")
423
+ + reset
424
+ )
425
+
426
+
427
+ @retention_group.command("delete")
428
+ @click.option("--scope", "-S", required=True, help="Scope path whose rule to remove.")
429
+ @click.pass_context
430
+ @handle_errors
431
+ def retention_delete(ctx: click.Context, scope: str) -> None:
432
+ """Remove one scope's rule (DELETE /v1/admin/retention/policies).
433
+
434
+ 404 when a well-formed store held no rule for exactly this scope -
435
+ inheritance is resolution, not storage, so a descendant that expires by
436
+ an ancestor's rule has no rule of its own to delete. Resets the durable
437
+ sweep cursor, like `set`.
438
+ """
439
+ cfg = ctx.obj
440
+ with get_client(cfg) as client:
441
+ resp = client.delete("/v1/admin/retention/policies", params={"scope": scope})
442
+ resp.raise_for_status()
443
+ if is_json_mode(cfg):
444
+ print_json({"scope": scope, "deleted": True, "cursor_reset": True})
445
+ return
446
+ print_success(f"Retention rule removed for {scope} (sweep cursor reset)")
447
+
448
+
449
+ @retention_group.command("sweep")
450
+ @click.option(
451
+ "--apply",
452
+ "apply_",
453
+ is_flag=True,
454
+ default=False,
455
+ help="ERASE expired events. Without it the tick only reports.",
456
+ )
457
+ @click.option("--scope", "-S", default=None, help="Confine the scan to this subtree.")
458
+ @click.option(
459
+ "--older-than-secs",
460
+ type=click.IntRange(min=1),
461
+ default=None,
462
+ help="Request-only TTL override for --scope. Never persisted.",
463
+ )
464
+ @click.option(
465
+ "--max-events",
466
+ type=click.IntRange(min=1),
467
+ default=None,
468
+ help="Override the per-tick expiry budget.",
469
+ )
470
+ @click.option(
471
+ "--rescan",
472
+ is_flag=True,
473
+ default=False,
474
+ help="Ignore the durable cursor and scan from the start of the log.",
475
+ )
476
+ @click.pass_context
477
+ @handle_errors
478
+ def retention_sweep(
479
+ ctx: click.Context,
480
+ apply_: bool,
481
+ scope: str | None,
482
+ older_than_secs: int | None,
483
+ max_events: int | None,
484
+ rescan: bool,
485
+ ) -> None:
486
+ """Run one retention tick (POST /v1/admin/retention/sweep).
487
+
488
+ DRY RUN IS THE DEFAULT - the tick reports what would be erased and
489
+ writes nothing. `--apply` is what sends `dry_run: false`, which erases
490
+ expired captured events irreversibly and needs `admin.compact`.
491
+
492
+ Single-flight: a second concurrent tick is refused with 409
493
+ RETENTION_SWEEP_RUNNING rather than queued.
494
+ """
495
+ cfg = ctx.obj
496
+ if older_than_secs is not None and not scope:
497
+ raise click.UsageError("--older-than-secs requires --scope.")
498
+ body: dict[str, Any] = {"dry_run": not apply_}
499
+ if scope:
500
+ body["scope"] = scope
501
+ if older_than_secs is not None:
502
+ body["older_than_secs"] = older_than_secs
503
+ if max_events is not None:
504
+ body["max_events"] = max_events
505
+ if rescan:
506
+ body["rescan"] = True
507
+ with get_client(cfg) as client:
508
+ if not is_json_mode(cfg):
509
+ with spinner("Sweeping..."):
510
+ resp = client.post("/v1/admin/retention/sweep", json=body)
511
+ else:
512
+ resp = client.post("/v1/admin/retention/sweep", json=body)
513
+ resp.raise_for_status()
514
+ _print_sweep_report(resp.json(), cfg)
515
+
516
+
517
+ @admin_group.group("wal")
518
+ def wal_group() -> None:
519
+ """Unified write-ahead log - census and dead-row compaction.
520
+
521
+ Requires trusted-operator access. A legacy data dir is refused with 409
522
+ RETENTION_UNSUPPORTED_BACKEND naming the offline migrator.
523
+ """
524
+
525
+
526
+ @wal_group.command("status")
527
+ @click.option(
528
+ "--verify",
529
+ is_flag=True,
530
+ default=False,
531
+ help=(
532
+ "Also run the bounded row-level self-check (clamped server-side). "
533
+ "The check is bounded at 1M rows / 512 MiB, which a production-sized "
534
+ "log exceeds: it then reports 'not run', which is not a failure."
535
+ ),
536
+ )
537
+ @click.pass_context
538
+ @handle_errors
539
+ def wal_status(ctx: click.Context, verify: bool) -> None:
540
+ """Live rows, offsets, consumer floors, markers (GET /v1/admin/wal)."""
541
+ cfg = ctx.obj
542
+ params = {"verify": "true"} if verify else None
543
+ with get_client(cfg) as client:
544
+ if not is_json_mode(cfg):
545
+ with spinner("Loading WAL census..."):
546
+ resp = client.get("/v1/admin/wal", params=params)
547
+ else:
548
+ resp = client.get("/v1/admin/wal", params=params)
549
+ resp.raise_for_status()
550
+ _print_wal_status(resp.json(), cfg)
551
+
552
+
553
+ @wal_group.command("compact")
554
+ @click.option(
555
+ "--apply",
556
+ "apply_",
557
+ is_flag=True,
558
+ default=False,
559
+ help="Physically remove the dead rows. Without it the run only reports.",
560
+ )
561
+ @click.option(
562
+ "--max-rows",
563
+ type=click.IntRange(min=1),
564
+ default=None,
565
+ help="Upper bound on dead rows removed in one sweep.",
566
+ )
567
+ @click.option(
568
+ "--below-offset",
569
+ type=click.IntRange(min=0),
570
+ default=None,
571
+ help="Exclusive offset bound. Defaults to the indexer watermark.",
572
+ )
573
+ @click.option(
574
+ "--compaction",
575
+ type=click.Choice(["none", "touched_ranges", "full"]),
576
+ default=None,
577
+ help="Physical reclamation mode. `full` is a whole-CF compaction.",
578
+ )
579
+ @click.pass_context
580
+ @handle_errors
581
+ def wal_compact(
582
+ ctx: click.Context,
583
+ apply_: bool,
584
+ max_rows: int | None,
585
+ below_offset: int | None,
586
+ compaction: str | None,
587
+ ) -> None:
588
+ """Reclaim already-dead WAL rows (POST /v1/admin/wal/compact).
589
+
590
+ Removes only rows that are ALREADY logically dead - redaction
591
+ tombstones and orphaned Derived twins. It never removes a live row and
592
+ never touches derived-artifact history.
593
+
594
+ DRY RUN IS THE DEFAULT; `--apply` is what sends `dry_run: false` and
595
+ needs `admin.compact`. Single-flight: 409 WAL_COMPACTION_RUNNING.
596
+ """
597
+ cfg = ctx.obj
598
+ body: dict[str, Any] = {"dry_run": not apply_}
599
+ if max_rows is not None:
600
+ body["max_rows"] = max_rows
601
+ if below_offset is not None:
602
+ body["below_offset"] = below_offset
603
+ if compaction is not None:
604
+ body["compaction"] = compaction
605
+ with get_client(cfg) as client:
606
+ if not is_json_mode(cfg):
607
+ with spinner("Compacting..."):
608
+ resp = client.post("/v1/admin/wal/compact", json=body)
609
+ else:
610
+ resp = client.post("/v1/admin/wal/compact", json=body)
611
+ resp.raise_for_status()
612
+ _print_compaction(resp.json(), cfg)