claude-smart 0.2.47 → 0.2.48
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
|
@@ -1,22 +1,34 @@
|
|
|
1
|
-
"""Process-local scheduler for lineage tombstone garbage collection.
|
|
1
|
+
"""Process-local scheduler for lineage tombstone garbage collection and expiry reclamation.
|
|
2
2
|
|
|
3
|
-
Startup
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
captured as Sentry anomalies and the loop continues to the next org.
|
|
3
|
+
Startup runs when EITHER ``lineage_gc.enabled`` OR ``expiry_reclamation.enabled``
|
|
4
|
+
OR ``governance_retention.audit_events_retention_enabled`` is set in the bootstrap
|
|
5
|
+
org's config. Each tick evaluates every org independently.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
- **Class A** (profile expiry sweep + tombstone GC): gated on ``lineage_gc.enabled``.
|
|
8
|
+
Hard-deletes expired tombstones per that org's ``lineage_gc`` config.
|
|
9
|
+
- **Class B** (plain-row direct-delete sweeps, no PII/audit obligation): gated on
|
|
10
|
+
``expiry_reclamation.enabled``. Currently sweeps expired share links and expired
|
|
11
|
+
pending tool calls.
|
|
12
|
+
- **Per-org sweeps**: registered via :func:`register_per_org_sweep`; invoked
|
|
13
|
+
unconditionally once per org after the Class-B block. Enterprise registers a
|
|
14
|
+
governance-retention closure here at startup (see ``reflexio_ext``).
|
|
15
|
+
|
|
16
|
+
One org's failure never stalls the loop; errors are captured as Sentry anomalies
|
|
17
|
+
and the loop continues to the next org.
|
|
18
|
+
|
|
19
|
+
Note: the poll interval is always taken from ``lineage_gc.poll_interval_seconds``
|
|
20
|
+
even when only ``expiry_reclamation`` or ``governance_retention`` is enabled;
|
|
21
|
+
all sweep classes share that cadence.
|
|
10
22
|
"""
|
|
11
23
|
|
|
12
24
|
from __future__ import annotations
|
|
13
25
|
|
|
14
26
|
import logging
|
|
15
|
-
import threading
|
|
16
27
|
import time
|
|
17
28
|
from collections.abc import Callable
|
|
18
29
|
|
|
19
30
|
from reflexio.server.api_endpoints.request_context import RequestContext
|
|
31
|
+
from reflexio.server.scheduling import ThreadedScheduler
|
|
20
32
|
from reflexio.server.tracing import capture_anomaly
|
|
21
33
|
|
|
22
34
|
logger = logging.getLogger(__name__)
|
|
@@ -30,8 +42,94 @@ _HIGH_VOLUME_THRESHOLD = 1000
|
|
|
30
42
|
|
|
31
43
|
_ENTITY_TYPES = ("user_playbook", "agent_playbook", "profile")
|
|
32
44
|
|
|
45
|
+
# Class B: direct-delete sweeps for expired plain rows (no PII/audit obligation).
|
|
46
|
+
# Each entry is (storage_method_name, grace_seconds, batch_limit).
|
|
47
|
+
# Methods added in Tasks 2.2 / 2.3; getattr-guarded so missing impls are skipped.
|
|
48
|
+
_CLASS_B_SWEEPS: tuple[tuple[str, int, int], ...] = (
|
|
49
|
+
("delete_expired_share_links", 7 * 86400, 1000),
|
|
50
|
+
("delete_expired_pending_tool_calls", 1 * 86400, 1000),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Injection seam: a deployment (e.g. enterprise/managed) sets a tenant-enumerating
|
|
54
|
+
# provider here at startup so the single OSS-started scheduler sweeps ALL tenant
|
|
55
|
+
# orgs. maybe_start_lineage_gc reads this when no explicit provider is passed.
|
|
56
|
+
# None (OSS default) keeps behavior byte-for-byte identical to before.
|
|
57
|
+
_org_id_provider_hook: Callable[[], list[str]] | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_org_id_provider(provider: Callable[[], list[str]] | None) -> None:
|
|
61
|
+
"""Register the org-id provider consulted by ``maybe_start_lineage_gc``.
|
|
62
|
+
|
|
63
|
+
Managed multi-tenant deployments call this at app-construction time so the
|
|
64
|
+
lineage GC / expiry sweep reaches every tenant org (``storage.list_org_ids()``
|
|
65
|
+
raises ``NotImplementedError`` on Supabase, degrading to bootstrap-only).
|
|
66
|
+
Pass ``None`` to clear the hook (used by tests to restore OSS defaults).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
provider (Callable[[], list[str]] | None): Callable returning the tenant
|
|
70
|
+
org ids to sweep, or ``None`` to clear.
|
|
71
|
+
"""
|
|
72
|
+
global _org_id_provider_hook # noqa: PLW0603
|
|
73
|
+
_org_id_provider_hook = provider
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Global sweeps run once per tick (for GLOBAL tables like invitation_codes),
|
|
77
|
+
# gated on expiry_reclamation.enabled. Each fn takes `now` (unix epoch) and
|
|
78
|
+
# returns a deleted-row count. Enterprise registers its sweeps here at startup;
|
|
79
|
+
# empty (OSS default) keeps behavior byte-for-byte identical to before.
|
|
80
|
+
_global_sweep_hooks: list[Callable[[int], int]] = []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def register_global_sweep(fn: Callable[[int], int]) -> None:
|
|
84
|
+
"""Register a global (once-per-tick) reclamation sweep.
|
|
85
|
+
|
|
86
|
+
Note: a registered sweep that absorbs its own exceptions (returns instead
|
|
87
|
+
of raising) will NOT trigger the generic ``lineage.global_sweep.failed``
|
|
88
|
+
backstop; such a sweep must emit its own failure anomaly.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
fn (Callable[[int], int]): Called with the current unix epoch; returns
|
|
92
|
+
the number of rows it deleted.
|
|
93
|
+
"""
|
|
94
|
+
_global_sweep_hooks.append(fn)
|
|
95
|
+
|
|
33
96
|
|
|
34
|
-
|
|
97
|
+
def clear_global_sweeps() -> None:
|
|
98
|
+
"""Clear all registered global sweeps (tests restore OSS defaults)."""
|
|
99
|
+
_global_sweep_hooks.clear()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Per-org sweeps run once per org per tick (for per-org reclamation concerns).
|
|
103
|
+
# Each fn takes (org_id, now) and returns a deleted-row count. Enterprise
|
|
104
|
+
# registers its closure here at startup so governance retention folds into the
|
|
105
|
+
# shared scheduler loop; empty (OSS default) keeps behavior identical to before.
|
|
106
|
+
_per_org_sweep_hooks: list[Callable[[str, int], int]] = []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def register_per_org_sweep(fn: Callable[[str, int], int]) -> None:
|
|
110
|
+
"""Register a per-org reclamation sweep.
|
|
111
|
+
|
|
112
|
+
The sweep is invoked once per org inside ``_gc_tick``'s org loop, after
|
|
113
|
+
the Class-B block, unconditionally (not gated on ``lineage_gc`` or
|
|
114
|
+
``expiry_reclamation`` — the real gate lives in the enterprise closure).
|
|
115
|
+
|
|
116
|
+
Note: a registered sweep that absorbs its own exceptions (returns instead
|
|
117
|
+
of raising) will NOT trigger the generic ``lineage.per_org_sweep.failed``
|
|
118
|
+
backstop; such a sweep must emit its own failure anomaly.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
fn (Callable[[str, int], int]): Called with ``(org_id, now)`` where
|
|
122
|
+
``now`` is the current unix epoch; returns the number of rows deleted.
|
|
123
|
+
"""
|
|
124
|
+
_per_org_sweep_hooks.append(fn)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def clear_per_org_sweeps() -> None:
|
|
128
|
+
"""Clear all registered per-org sweeps (tests restore OSS defaults)."""
|
|
129
|
+
_per_org_sweep_hooks.clear()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class LineageGCScheduler(ThreadedScheduler):
|
|
35
133
|
"""Polling daemon that hard-deletes expired tombstones per org."""
|
|
36
134
|
|
|
37
135
|
def __init__(
|
|
@@ -39,48 +137,62 @@ class LineageGCScheduler:
|
|
|
39
137
|
*,
|
|
40
138
|
request_context_factory: Callable[[str], RequestContext],
|
|
41
139
|
bootstrap_org_id: str,
|
|
140
|
+
org_id_provider: Callable[[], list[str]] | None = None,
|
|
42
141
|
) -> None:
|
|
142
|
+
super().__init__(thread_name="reflexio-lineage-gc-scheduler")
|
|
43
143
|
self.request_context_factory = request_context_factory
|
|
44
144
|
self.bootstrap_org_id = bootstrap_org_id
|
|
45
|
-
|
|
46
|
-
|
|
145
|
+
# Optional injectable org-id source. When set (managed/multi-tenant mode),
|
|
146
|
+
# it is the authoritative enumeration seam; enterprise supplies a
|
|
147
|
+
# provider that lists tenant orgs via the control-plane repository — the
|
|
148
|
+
# only seam that surfaces managed tenants where storage.list_org_ids()
|
|
149
|
+
# raises NotImplementedError on Supabase. When None (OSS default),
|
|
150
|
+
# discovery falls back to storage.list_org_ids() exactly as before.
|
|
151
|
+
self.org_id_provider = org_id_provider
|
|
47
152
|
|
|
48
|
-
def
|
|
49
|
-
"""Start the daemon thread (idempotent if already running)."""
|
|
50
|
-
if self._thread is not None and self._thread.is_alive():
|
|
51
|
-
return
|
|
52
|
-
self._stop_event.clear()
|
|
53
|
-
self._thread = threading.Thread(
|
|
54
|
-
target=self._run_loop,
|
|
55
|
-
name="reflexio-lineage-gc-scheduler",
|
|
56
|
-
daemon=True,
|
|
57
|
-
)
|
|
58
|
-
self._thread.start()
|
|
153
|
+
def _on_started(self) -> None:
|
|
59
154
|
logger.info("event=lineage_gc_scheduler_started")
|
|
60
155
|
|
|
61
|
-
def
|
|
62
|
-
"""Signal the daemon to stop and wait for it to finish."""
|
|
63
|
-
self._stop_event.set()
|
|
64
|
-
if self._thread is not None:
|
|
65
|
-
self._thread.join(timeout=timeout_seconds)
|
|
66
|
-
self._thread = None
|
|
156
|
+
def _on_stopped(self) -> None:
|
|
67
157
|
logger.info("event=lineage_gc_scheduler_stopped")
|
|
68
158
|
|
|
69
159
|
def _discover_org_ids(self, bootstrap_ctx: RequestContext) -> list[str]:
|
|
70
|
-
"""Return every known org, always including the bootstrap org.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
160
|
+
"""Return every known org, always including the bootstrap org.
|
|
161
|
+
|
|
162
|
+
When an ``org_id_provider`` was injected (managed/multi-tenant mode), it
|
|
163
|
+
is the authoritative org-id source and ``storage.list_org_ids()`` is not
|
|
164
|
+
consulted. Otherwise the OSS single-tenant path is used unchanged
|
|
165
|
+
(``storage.list_org_ids()`` with a visible NotImplementedError fallback).
|
|
166
|
+
The bootstrap org is always unioned in either way.
|
|
167
|
+
"""
|
|
168
|
+
if self.org_id_provider is not None:
|
|
74
169
|
try:
|
|
75
|
-
org_ids =
|
|
76
|
-
except
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
170
|
+
org_ids = list(self.org_id_provider())
|
|
171
|
+
except Exception:
|
|
172
|
+
capture_anomaly(
|
|
173
|
+
"lineage.gc.org_id_provider_failed",
|
|
174
|
+
bootstrap_org_id=self.bootstrap_org_id,
|
|
175
|
+
)
|
|
176
|
+
logger.exception(
|
|
177
|
+
"event=lineage_gc_org_id_provider_failed bootstrap_org_id=%s "
|
|
178
|
+
"— falling back to bootstrap org only",
|
|
179
|
+
self.bootstrap_org_id,
|
|
82
180
|
)
|
|
83
181
|
org_ids = []
|
|
182
|
+
else:
|
|
183
|
+
storage = getattr(bootstrap_ctx, "storage", None)
|
|
184
|
+
org_ids = []
|
|
185
|
+
if storage is not None:
|
|
186
|
+
try:
|
|
187
|
+
org_ids = storage.list_org_ids()
|
|
188
|
+
except NotImplementedError:
|
|
189
|
+
logger.warning(
|
|
190
|
+
"event=lineage_gc_list_org_ids_not_implemented "
|
|
191
|
+
"backend=%s bootstrap_org_id=%s — GC will only process bootstrap org",
|
|
192
|
+
type(storage).__name__,
|
|
193
|
+
bootstrap_ctx.org_id,
|
|
194
|
+
)
|
|
195
|
+
org_ids = []
|
|
84
196
|
if bootstrap_ctx.org_id not in org_ids:
|
|
85
197
|
org_ids = [bootstrap_ctx.org_id, *org_ids]
|
|
86
198
|
return org_ids
|
|
@@ -101,58 +213,198 @@ class LineageGCScheduler:
|
|
|
101
213
|
if ctx.storage is None:
|
|
102
214
|
continue
|
|
103
215
|
cfg = ctx.configurator.get_config()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
216
|
+
except Exception:
|
|
217
|
+
capture_anomaly("lineage.gc.run_failed", org_id=org_id)
|
|
218
|
+
logger.exception("event=lineage_gc_org_failed org_id=%s", org_id)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Class A: profile expiry sweep (requires PII/grace sign-off; gated on
|
|
222
|
+
# lineage_gc.enabled independently of Class B).
|
|
223
|
+
if cfg.lineage_gc.enabled:
|
|
224
|
+
try:
|
|
225
|
+
expired_tombstoned = ctx.storage.expire_active_profiles(
|
|
226
|
+
now=int(time.time())
|
|
227
|
+
)
|
|
228
|
+
if expired_tombstoned:
|
|
229
|
+
logger.info(
|
|
230
|
+
"event=expiry_sweep org_id=%s profiles_tombstoned=%d",
|
|
231
|
+
org_id,
|
|
232
|
+
expired_tombstoned,
|
|
233
|
+
)
|
|
234
|
+
if expired_tombstoned > _HIGH_VOLUME_THRESHOLD:
|
|
235
|
+
capture_anomaly(
|
|
236
|
+
"lineage.expiry_sweep.high_volume",
|
|
237
|
+
org_id=org_id,
|
|
238
|
+
count=expired_tombstoned,
|
|
239
|
+
)
|
|
240
|
+
except Exception:
|
|
241
|
+
capture_anomaly("lineage.expiry_sweep.failed", org_id=org_id)
|
|
242
|
+
logger.exception(
|
|
243
|
+
"event=lineage_expiry_sweep_failed org_id=%s", org_id
|
|
115
244
|
)
|
|
116
|
-
|
|
245
|
+
|
|
246
|
+
# Tombstone GC: each entity type is independent within the loop.
|
|
247
|
+
try:
|
|
248
|
+
older_than_epoch = (
|
|
249
|
+
int(time.time())
|
|
250
|
+
- cfg.lineage_gc.tombstone_grace_window_days * 86400
|
|
251
|
+
)
|
|
252
|
+
tombstone_deleted = 0
|
|
253
|
+
for entity_type in _ENTITY_TYPES:
|
|
254
|
+
tombstone_deleted += ctx.storage.gc_expired_tombstones(
|
|
255
|
+
entity_type=entity_type,
|
|
256
|
+
older_than_epoch=older_than_epoch,
|
|
257
|
+
)
|
|
258
|
+
if tombstone_deleted:
|
|
259
|
+
logger.info(
|
|
260
|
+
"event=lineage_gc_tick org_id=%s tombstone_deleted=%d",
|
|
261
|
+
org_id,
|
|
262
|
+
tombstone_deleted,
|
|
263
|
+
)
|
|
264
|
+
if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
|
|
265
|
+
capture_anomaly(
|
|
266
|
+
"lineage.gc.high_volume",
|
|
267
|
+
org_id=org_id,
|
|
268
|
+
count=tombstone_deleted,
|
|
269
|
+
)
|
|
270
|
+
except Exception:
|
|
271
|
+
capture_anomaly("lineage.gc.tombstone_gc_failed", org_id=org_id)
|
|
272
|
+
logger.exception(
|
|
273
|
+
"event=lineage_gc_tombstone_gc_failed org_id=%s", org_id
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Class B: direct-delete of expired plain rows (no audit/grace
|
|
277
|
+
# obligation; independent of lineage_gc). Each sweep is isolated so
|
|
278
|
+
# one failing method does not skip the rest.
|
|
279
|
+
if (
|
|
280
|
+
getattr(cfg, "expiry_reclamation", None) is not None
|
|
281
|
+
and cfg.expiry_reclamation.enabled
|
|
282
|
+
):
|
|
283
|
+
now = int(time.time())
|
|
284
|
+
for method_name, grace, limit in _CLASS_B_SWEEPS:
|
|
285
|
+
method = getattr(ctx.storage, method_name, None)
|
|
286
|
+
if method is None:
|
|
287
|
+
continue
|
|
288
|
+
try:
|
|
289
|
+
deleted = method(now=now, grace_seconds=grace, limit=limit)
|
|
290
|
+
if deleted:
|
|
291
|
+
logger.info(
|
|
292
|
+
"event=class_b_reclaim org_id=%s method=%s deleted=%d",
|
|
293
|
+
org_id,
|
|
294
|
+
method_name,
|
|
295
|
+
deleted,
|
|
296
|
+
)
|
|
297
|
+
except Exception:
|
|
298
|
+
capture_anomaly(
|
|
299
|
+
"lineage.class_b_reclaim.failed",
|
|
300
|
+
org_id=org_id,
|
|
301
|
+
method=method_name,
|
|
302
|
+
)
|
|
303
|
+
logger.exception(
|
|
304
|
+
"event=class_b_reclaim_failed org_id=%s method=%s",
|
|
305
|
+
org_id,
|
|
306
|
+
method_name,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Per-org sweeps: invoked unconditionally once per org (the real gate
|
|
310
|
+
# lives in each enterprise closure). Extracted to keep _gc_tick's
|
|
311
|
+
# cyclomatic complexity in check and to mirror _run_global_sweeps.
|
|
312
|
+
self._run_per_org_sweeps(org_id)
|
|
313
|
+
|
|
314
|
+
def _run_per_org_sweeps(self, org_id: str) -> None:
|
|
315
|
+
"""Invoke each registered per-org sweep once for this org.
|
|
316
|
+
|
|
317
|
+
Invoked unconditionally (not gated on ``lineage_gc``/``expiry_reclamation``
|
|
318
|
+
— the real gate lives in each enterprise closure). Per-sweep failure
|
|
319
|
+
isolation: one sweep raising does not skip the others; a sweep that
|
|
320
|
+
absorbs its own exceptions bypasses the ``lineage.per_org_sweep.failed``
|
|
321
|
+
backstop and must emit its own anomaly.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
org_id: The org being swept this tick.
|
|
325
|
+
"""
|
|
326
|
+
now = int(time.time())
|
|
327
|
+
for sweep in _per_org_sweep_hooks:
|
|
328
|
+
sweep_id = getattr(sweep, "__qualname__", repr(sweep))
|
|
329
|
+
try:
|
|
330
|
+
deleted = sweep(org_id, now)
|
|
331
|
+
if deleted:
|
|
117
332
|
logger.info(
|
|
118
|
-
"event=
|
|
333
|
+
"event=per_org_sweep org_id=%s sweep=%s deleted=%d",
|
|
119
334
|
org_id,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if tombstone_deleted > _HIGH_VOLUME_THRESHOLD:
|
|
123
|
-
capture_anomaly(
|
|
124
|
-
"lineage.gc.high_volume",
|
|
125
|
-
org_id=org_id,
|
|
126
|
-
count=tombstone_deleted,
|
|
335
|
+
sweep_id,
|
|
336
|
+
deleted,
|
|
127
337
|
)
|
|
128
338
|
except Exception:
|
|
129
|
-
capture_anomaly(
|
|
130
|
-
|
|
339
|
+
capture_anomaly(
|
|
340
|
+
"lineage.per_org_sweep.failed",
|
|
341
|
+
org_id=org_id,
|
|
342
|
+
sweep=sweep_id,
|
|
343
|
+
)
|
|
344
|
+
logger.exception(
|
|
345
|
+
"event=per_org_sweep_failed org_id=%s sweep=%s",
|
|
346
|
+
org_id,
|
|
347
|
+
sweep_id,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _run_global_sweeps(self, cfg: object) -> None:
|
|
351
|
+
"""Invoke each registered global sweep once, gated on expiry_reclamation.
|
|
352
|
+
|
|
353
|
+
Per-sweep failure isolation: one sweep raising does not skip the others.
|
|
354
|
+
The specific per-entity anomaly is emitted inside each registered sweep;
|
|
355
|
+
this is a generic backstop.
|
|
131
356
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
357
|
+
Args:
|
|
358
|
+
cfg: The bootstrap-org config read for this tick.
|
|
359
|
+
"""
|
|
360
|
+
expiry = getattr(cfg, "expiry_reclamation", None)
|
|
361
|
+
if expiry is None or not expiry.enabled:
|
|
362
|
+
return
|
|
363
|
+
now = int(time.time())
|
|
364
|
+
for sweep in _global_sweep_hooks:
|
|
135
365
|
try:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
org_ids = self._discover_org_ids(bootstrap_ctx)
|
|
140
|
-
self._gc_tick(org_ids)
|
|
366
|
+
deleted = sweep(now)
|
|
367
|
+
if deleted:
|
|
368
|
+
logger.info("event=global_sweep deleted=%d", deleted)
|
|
141
369
|
except Exception:
|
|
142
|
-
|
|
143
|
-
|
|
370
|
+
sweep_id = getattr(sweep, "__qualname__", repr(sweep))
|
|
371
|
+
capture_anomaly("lineage.global_sweep.failed", sweep=sweep_id)
|
|
372
|
+
logger.exception("event=global_sweep_failed sweep=%s", sweep_id)
|
|
373
|
+
|
|
374
|
+
def _run_once(self) -> float:
|
|
375
|
+
poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
|
|
376
|
+
try:
|
|
377
|
+
bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
|
|
378
|
+
cfg = bootstrap_ctx.configurator.get_config()
|
|
379
|
+
poll_interval = cfg.lineage_gc.poll_interval_seconds
|
|
380
|
+
org_ids = self._discover_org_ids(bootstrap_ctx)
|
|
381
|
+
self._gc_tick(org_ids)
|
|
382
|
+
self._run_global_sweeps(cfg)
|
|
383
|
+
except Exception:
|
|
384
|
+
logger.exception("event=lineage_gc_scheduler_tick_failed")
|
|
385
|
+
return max(poll_interval, _MIN_POLL_SECONDS)
|
|
144
386
|
|
|
145
387
|
|
|
146
388
|
def maybe_start_lineage_gc(
|
|
147
389
|
request_context_factory: Callable[[str], RequestContext],
|
|
148
390
|
*,
|
|
149
391
|
bootstrap_org_id: str,
|
|
392
|
+
org_id_provider: Callable[[], list[str]] | None = None,
|
|
150
393
|
) -> LineageGCScheduler | None:
|
|
151
|
-
"""Start the scheduler
|
|
394
|
+
"""Start the scheduler when bootstrap config enables tombstone GC or expiry reclamation.
|
|
395
|
+
|
|
396
|
+
Startup runs when the bootstrap-org config sets ``lineage_gc.enabled``
|
|
397
|
+
(Class A: profile expiry + tombstone GC), ``expiry_reclamation.enabled``
|
|
398
|
+
(Class B: plain-row direct-delete sweeps), or
|
|
399
|
+
``governance_retention.audit_events_retention_enabled`` — OR when any sweep
|
|
400
|
+
hook has been registered via :func:`register_per_org_sweep` /
|
|
401
|
+
:func:`register_global_sweep`. Registered hooks carry their own per-org gate
|
|
402
|
+
in the enterprise closure, so the scheduler must start to evaluate them even
|
|
403
|
+
when none of the bootstrap-org flags are set. Returns ``None`` only if none
|
|
404
|
+
of these conditions hold.
|
|
152
405
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
ALL hold before enabling for a production org):
|
|
406
|
+
Tombstone-GC enablement criteria (must ALL hold before enabling ``lineage_gc``
|
|
407
|
+
for a production org):
|
|
156
408
|
|
|
157
409
|
1. **Mechanism**: GC ages tombstones by ``retired_at`` (the INTEGER epoch
|
|
158
410
|
written at every tombstone write-path). Rows with ``retired_at = NULL``
|
|
@@ -167,22 +419,31 @@ def maybe_start_lineage_gc(
|
|
|
167
419
|
4. **DPO sign-off**: obtain sign-off on the PII-lifetime and audit-depth
|
|
168
420
|
implications before enabling in any deployment that processes personal data.
|
|
169
421
|
|
|
422
|
+
Note: the poll cadence is always controlled by ``lineage_gc.poll_interval_seconds``
|
|
423
|
+
even when only ``expiry_reclamation`` is enabled; Class B shares that interval.
|
|
424
|
+
|
|
170
425
|
Args:
|
|
171
426
|
request_context_factory: Builds an org-scoped :class:`RequestContext`.
|
|
172
427
|
bootstrap_org_id: Org used to read config and seed cross-org discovery.
|
|
428
|
+
org_id_provider: Optional tenant-enumerating org-id source. When ``None``
|
|
429
|
+
(OSS default), falls back to the module-level provider hook set via
|
|
430
|
+
:func:`set_org_id_provider`; when both are ``None`` discovery uses
|
|
431
|
+
``storage.list_org_ids()`` exactly as before.
|
|
173
432
|
|
|
174
433
|
Returns:
|
|
175
|
-
LineageGCScheduler: The started scheduler, or ``None`` if
|
|
434
|
+
LineageGCScheduler: The started scheduler, or ``None`` if no start
|
|
435
|
+
condition holds (see the startup criteria above).
|
|
176
436
|
"""
|
|
177
437
|
try:
|
|
178
438
|
ctx = request_context_factory(bootstrap_org_id)
|
|
179
439
|
cfg = ctx.configurator.get_config()
|
|
180
440
|
|
|
181
441
|
# Dead-knob warning: audit-event retention is an ENTERPRISE-only feature
|
|
182
|
-
# (handled by
|
|
183
|
-
# deployment the knob is accepted
|
|
184
|
-
# via the configurator class:
|
|
185
|
-
# construction, so the OSS
|
|
442
|
+
# (handled by an enterprise per-org reclamation sweep registered via
|
|
443
|
+
# register_per_org_sweep). In an OSS-only deployment the knob is accepted
|
|
444
|
+
# but does nothing — warn loudly. Detected via the configurator class:
|
|
445
|
+
# enterprise swaps in EnterpriseConfigurator at construction, so the OSS
|
|
446
|
+
# DefaultConfigurator means "no enterprise here".
|
|
186
447
|
from reflexio.server.services.configurator.configurator import ( # noqa: PLC0415
|
|
187
448
|
DefaultConfigurator,
|
|
188
449
|
get_configurator_class,
|
|
@@ -201,7 +462,24 @@ def maybe_start_lineage_gc(
|
|
|
201
462
|
"and will NOT run here."
|
|
202
463
|
)
|
|
203
464
|
|
|
204
|
-
|
|
465
|
+
expiry_reclamation_enabled = getattr(
|
|
466
|
+
getattr(cfg, "expiry_reclamation", None), "enabled", False
|
|
467
|
+
)
|
|
468
|
+
governance_retention_enabled = getattr(
|
|
469
|
+
gr, "audit_events_retention_enabled", False
|
|
470
|
+
)
|
|
471
|
+
# Also start when any reclamation sweep has been registered — registered
|
|
472
|
+
# hooks carry their own per-org gates (in the enterprise closure), so the
|
|
473
|
+
# scheduler must run even if the bootstrap org's config has all flags off.
|
|
474
|
+
# This preserves the "start unconditionally, gate per-org" invariant of
|
|
475
|
+
# the deleted GovernanceRetentionScheduler.
|
|
476
|
+
if not (
|
|
477
|
+
cfg.lineage_gc.enabled
|
|
478
|
+
or expiry_reclamation_enabled
|
|
479
|
+
or governance_retention_enabled
|
|
480
|
+
or bool(_per_org_sweep_hooks)
|
|
481
|
+
or bool(_global_sweep_hooks)
|
|
482
|
+
):
|
|
205
483
|
return None
|
|
206
484
|
except Exception as exc:
|
|
207
485
|
logger.warning(
|
|
@@ -214,6 +492,9 @@ def maybe_start_lineage_gc(
|
|
|
214
492
|
scheduler = LineageGCScheduler(
|
|
215
493
|
request_context_factory=request_context_factory,
|
|
216
494
|
bootstrap_org_id=bootstrap_org_id,
|
|
495
|
+
org_id_provider=(
|
|
496
|
+
org_id_provider if org_id_provider is not None else _org_id_provider_hook
|
|
497
|
+
),
|
|
217
498
|
)
|
|
218
499
|
scheduler.start()
|
|
219
500
|
return scheduler
|