claude-smart 0.2.46 → 0.2.47
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 +19 -11
- package/bin/claude-smart.js +290 -68
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/README.md +11 -10
- package/plugin/dashboard/app/layout.tsx +20 -0
- package/plugin/opencode/dist/server.mjs +76 -2
- package/plugin/opencode/server.mts +79 -2
- package/plugin/pyproject.toml +6 -2
- package/plugin/scripts/smart-install.sh +7 -1
- package/plugin/src/claude_smart/cli.py +210 -22
- package/plugin/src/claude_smart/context_format.py +9 -9
- package/plugin/src/claude_smart/cs_cite.py +66 -19
- package/plugin/uv.lock +5 -5
- package/plugin/vendor/reflexio/README.md +3 -3
- package/plugin/vendor/reflexio/pyproject.toml +1 -1
- package/plugin/vendor/reflexio/reflexio/README.md +3 -1
- package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
- package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
- package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
- package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
- package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
- package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
- package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
- package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +23 -5
- package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
- package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +16 -0
- package/plugin/vendor/reflexio/reflexio/server/README.md +14 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +176 -29
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
- package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1 -1
- package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
- package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
- package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
- package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
- package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +45 -19
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +129 -111
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
- package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
- package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
- package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +14 -2
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +1965 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +5 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +262 -107
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +952 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +838 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +19 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
- package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"""User playbook CRUD + search methods for SQLite storage."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import uuid
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from reflexio.models.api_schema.common import BlockingIssue
|
|
10
|
+
from reflexio.models.api_schema.retriever_schema import SearchUserPlaybookRequest
|
|
11
|
+
from reflexio.models.api_schema.service_schemas import Status, UserPlaybook
|
|
12
|
+
from reflexio.models.config_schema import SearchMode, SearchOptions
|
|
13
|
+
|
|
14
|
+
from .._base import (
|
|
15
|
+
_TOMBSTONE_STATUS_VALUES,
|
|
16
|
+
SQLiteStorageBase,
|
|
17
|
+
_build_status_sql,
|
|
18
|
+
_effective_search_mode,
|
|
19
|
+
_epoch_now,
|
|
20
|
+
_epoch_to_iso,
|
|
21
|
+
_json_dumps,
|
|
22
|
+
_row_to_user_playbook,
|
|
23
|
+
_sanitize_fts_query,
|
|
24
|
+
_true_rrf_merge,
|
|
25
|
+
_vector_rank_rows,
|
|
26
|
+
)
|
|
27
|
+
from .._lineage import _GC_ELIGIBLE_STATUSES, _append_event_stmt
|
|
28
|
+
from .._playbook import _build_tags_sql, _emit_hard_delete_playbook
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _emit_supersede_user_playbook(
|
|
32
|
+
conn: sqlite3.Connection,
|
|
33
|
+
*,
|
|
34
|
+
org_id: str,
|
|
35
|
+
entity_id: str,
|
|
36
|
+
old_status: str | None,
|
|
37
|
+
request_id: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Emit a single status_change->superseded lineage event for a user playbook."""
|
|
40
|
+
_append_event_stmt(
|
|
41
|
+
conn,
|
|
42
|
+
org_id=org_id,
|
|
43
|
+
entity_type="user_playbook",
|
|
44
|
+
entity_id=entity_id,
|
|
45
|
+
op="status_change",
|
|
46
|
+
prov="wasInvalidatedBy",
|
|
47
|
+
source_ids=[],
|
|
48
|
+
actor="consolidator",
|
|
49
|
+
request_id=request_id,
|
|
50
|
+
reason=f"{old_status or 'None'}->superseded",
|
|
51
|
+
from_status=old_status,
|
|
52
|
+
to_status=Status.SUPERSEDED.value,
|
|
53
|
+
status_namespace="lifecycle_status",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UserPlaybookStoreMixin:
|
|
58
|
+
"""Mixin providing user playbook CRUD + search for SQLite storage."""
|
|
59
|
+
|
|
60
|
+
# Type hints for instance attributes/methods provided by SQLiteStorageBase via MRO
|
|
61
|
+
_lock: Any
|
|
62
|
+
conn: sqlite3.Connection
|
|
63
|
+
org_id: str
|
|
64
|
+
_fetchone: Any
|
|
65
|
+
_fetchall: Any
|
|
66
|
+
_get_embedding: Any
|
|
67
|
+
_should_expand_documents: Any
|
|
68
|
+
_expand_document: Any
|
|
69
|
+
_fts_upsert: Any
|
|
70
|
+
_vec_upsert: Any
|
|
71
|
+
_delete_playbook_search_rows: Any
|
|
72
|
+
_subject_ref_for_user_id: Any
|
|
73
|
+
_assert_subject_writable_locked: Any
|
|
74
|
+
|
|
75
|
+
def _subject_ref_from_user_playbook_row(self, row: sqlite3.Row) -> str:
|
|
76
|
+
subject_ref = row["governance_subject_ref"]
|
|
77
|
+
if subject_ref:
|
|
78
|
+
return str(subject_ref)
|
|
79
|
+
user_id = row["user_id"]
|
|
80
|
+
if user_id is None or str(user_id) == "":
|
|
81
|
+
raise ValueError("User playbook subject identity is missing")
|
|
82
|
+
return self._subject_ref_for_user_id(str(user_id))
|
|
83
|
+
|
|
84
|
+
def _assert_user_playbook_writable_locked(
|
|
85
|
+
self,
|
|
86
|
+
user_playbook_id: int,
|
|
87
|
+
) -> sqlite3.Row | None:
|
|
88
|
+
row = self.conn.execute(
|
|
89
|
+
"SELECT user_id, governance_subject_ref FROM user_playbooks WHERE user_playbook_id = ?",
|
|
90
|
+
(user_playbook_id,),
|
|
91
|
+
).fetchone()
|
|
92
|
+
if row is None:
|
|
93
|
+
return None
|
|
94
|
+
self._assert_subject_writable_locked(
|
|
95
|
+
self._subject_ref_from_user_playbook_row(row)
|
|
96
|
+
)
|
|
97
|
+
return row
|
|
98
|
+
|
|
99
|
+
@SQLiteStorageBase.handle_exceptions
|
|
100
|
+
def save_user_playbooks(self, user_playbooks: list[UserPlaybook]) -> None:
|
|
101
|
+
for up in user_playbooks:
|
|
102
|
+
subject_ref = self._subject_ref_for_user_id(up.user_id)
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._assert_subject_writable_locked(subject_ref)
|
|
105
|
+
embedding_text = up.trigger or up.content
|
|
106
|
+
if embedding_text:
|
|
107
|
+
if self._should_expand_documents():
|
|
108
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
109
|
+
emb_future = executor.submit(
|
|
110
|
+
self._get_embedding, embedding_text
|
|
111
|
+
)
|
|
112
|
+
exp_future = executor.submit(
|
|
113
|
+
self._expand_document, embedding_text
|
|
114
|
+
)
|
|
115
|
+
up.embedding = emb_future.result(timeout=15)
|
|
116
|
+
up.expanded_terms = exp_future.result(timeout=15)
|
|
117
|
+
else:
|
|
118
|
+
up.embedding = self._get_embedding(embedding_text)
|
|
119
|
+
|
|
120
|
+
created_at_iso = _epoch_to_iso(up.created_at)
|
|
121
|
+
with self._lock:
|
|
122
|
+
try:
|
|
123
|
+
self.conn.execute("BEGIN IMMEDIATE")
|
|
124
|
+
self._assert_subject_writable_locked(subject_ref)
|
|
125
|
+
cur = self.conn.execute(
|
|
126
|
+
"""INSERT INTO user_playbooks
|
|
127
|
+
(user_id, playbook_name, created_at, request_id, agent_version,
|
|
128
|
+
content, trigger, rationale, blocking_issue,
|
|
129
|
+
source_interaction_ids,
|
|
130
|
+
status, source, embedding, expanded_terms,
|
|
131
|
+
source_span, notes, reader_angle, tags,
|
|
132
|
+
merged_into, superseded_by, governance_subject_ref)
|
|
133
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
134
|
+
(
|
|
135
|
+
up.user_id,
|
|
136
|
+
up.playbook_name,
|
|
137
|
+
created_at_iso,
|
|
138
|
+
up.request_id,
|
|
139
|
+
up.agent_version,
|
|
140
|
+
up.content,
|
|
141
|
+
up.trigger,
|
|
142
|
+
up.rationale,
|
|
143
|
+
json.dumps(up.blocking_issue.model_dump())
|
|
144
|
+
if up.blocking_issue
|
|
145
|
+
else None,
|
|
146
|
+
_json_dumps(up.source_interaction_ids or None),
|
|
147
|
+
up.status.value if up.status else None,
|
|
148
|
+
up.source,
|
|
149
|
+
_json_dumps(up.embedding),
|
|
150
|
+
up.expanded_terms,
|
|
151
|
+
up.source_span,
|
|
152
|
+
up.notes,
|
|
153
|
+
up.reader_angle,
|
|
154
|
+
_json_dumps(up.tags),
|
|
155
|
+
up.merged_into,
|
|
156
|
+
up.superseded_by,
|
|
157
|
+
subject_ref,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
upid = cur.lastrowid or 0
|
|
161
|
+
up.user_playbook_id = upid
|
|
162
|
+
self.conn.commit()
|
|
163
|
+
except Exception:
|
|
164
|
+
self.conn.rollback()
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
fts_parts = [up.trigger or "", up.content or ""]
|
|
168
|
+
if up.expanded_terms:
|
|
169
|
+
fts_parts.append(up.expanded_terms)
|
|
170
|
+
self._fts_upsert(
|
|
171
|
+
"user_playbooks_fts",
|
|
172
|
+
upid,
|
|
173
|
+
search_text=" ".join(p for p in fts_parts if p) or "",
|
|
174
|
+
)
|
|
175
|
+
if up.embedding:
|
|
176
|
+
self._vec_upsert("user_playbooks_vec", upid, up.embedding)
|
|
177
|
+
|
|
178
|
+
@SQLiteStorageBase.handle_exceptions
|
|
179
|
+
def get_user_playbooks(
|
|
180
|
+
self,
|
|
181
|
+
limit: int = 100,
|
|
182
|
+
user_id: str | None = None,
|
|
183
|
+
playbook_name: str | None = None,
|
|
184
|
+
agent_version: str | None = None,
|
|
185
|
+
status_filter: list[Status | None] | None = None,
|
|
186
|
+
start_time: int | None = None,
|
|
187
|
+
end_time: int | None = None,
|
|
188
|
+
include_embedding: bool = False,
|
|
189
|
+
tags: list[str] | None = None,
|
|
190
|
+
offset: int = 0,
|
|
191
|
+
user_playbook_id: int | None = None,
|
|
192
|
+
request_id: str | None = None,
|
|
193
|
+
query: str | None = None,
|
|
194
|
+
) -> list[UserPlaybook]:
|
|
195
|
+
sql = "SELECT * FROM user_playbooks WHERE 1=1"
|
|
196
|
+
params: list[Any] = []
|
|
197
|
+
|
|
198
|
+
if user_playbook_id is not None:
|
|
199
|
+
sql += " AND user_playbook_id = ?"
|
|
200
|
+
params.append(user_playbook_id)
|
|
201
|
+
if user_id is not None:
|
|
202
|
+
sql += " AND user_id = ?"
|
|
203
|
+
params.append(user_id)
|
|
204
|
+
if request_id is not None:
|
|
205
|
+
sql += " AND request_id = ?"
|
|
206
|
+
params.append(request_id)
|
|
207
|
+
if query:
|
|
208
|
+
like = f"%{query.lower()}%"
|
|
209
|
+
sql += (
|
|
210
|
+
" AND (LOWER(content) LIKE ? OR LOWER(trigger) LIKE ? "
|
|
211
|
+
"OR LOWER(rationale) LIKE ? OR LOWER(request_id) LIKE ? "
|
|
212
|
+
"OR LOWER(playbook_name) LIKE ? OR LOWER(user_id) LIKE ?)"
|
|
213
|
+
)
|
|
214
|
+
params.extend([like, like, like, like, like, like])
|
|
215
|
+
if playbook_name:
|
|
216
|
+
sql += " AND playbook_name = ?"
|
|
217
|
+
params.append(playbook_name)
|
|
218
|
+
if agent_version is not None:
|
|
219
|
+
sql += " AND agent_version = ?"
|
|
220
|
+
params.append(agent_version)
|
|
221
|
+
if start_time is not None:
|
|
222
|
+
sql += " AND created_at >= ?"
|
|
223
|
+
params.append(_epoch_to_iso(start_time))
|
|
224
|
+
if end_time is not None:
|
|
225
|
+
sql += " AND created_at <= ?"
|
|
226
|
+
params.append(_epoch_to_iso(end_time))
|
|
227
|
+
if status_filter is not None:
|
|
228
|
+
frag, sparams = _build_status_sql(status_filter)
|
|
229
|
+
sql += f" AND {frag}"
|
|
230
|
+
params.extend(sparams)
|
|
231
|
+
else:
|
|
232
|
+
# Default: exclude tombstone statuses (MERGED/SUPERSEDED)
|
|
233
|
+
sql += " AND (status IS NULL OR status NOT IN (?, ?))"
|
|
234
|
+
params.extend(_TOMBSTONE_STATUS_VALUES)
|
|
235
|
+
tag_frag, tag_params = _build_tags_sql("user_playbooks", tags)
|
|
236
|
+
if tag_frag:
|
|
237
|
+
sql += f" AND {tag_frag}"
|
|
238
|
+
params.extend(tag_params)
|
|
239
|
+
|
|
240
|
+
sql += " ORDER BY created_at DESC, user_playbook_id DESC LIMIT ? OFFSET ?"
|
|
241
|
+
params.extend([limit, offset])
|
|
242
|
+
rows = self._fetchall(sql, params)
|
|
243
|
+
return [
|
|
244
|
+
_row_to_user_playbook(r, include_embedding=include_embedding) for r in rows
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
@SQLiteStorageBase.handle_exceptions
|
|
248
|
+
def count_user_playbooks(
|
|
249
|
+
self,
|
|
250
|
+
user_id: str | None = None,
|
|
251
|
+
playbook_name: str | None = None,
|
|
252
|
+
min_user_playbook_id: int | None = None,
|
|
253
|
+
agent_version: str | None = None,
|
|
254
|
+
status_filter: list[Status | None] | None = None,
|
|
255
|
+
) -> int:
|
|
256
|
+
sql = "SELECT COUNT(*) as cnt FROM user_playbooks WHERE 1=1"
|
|
257
|
+
params: list[Any] = []
|
|
258
|
+
|
|
259
|
+
if user_id is not None:
|
|
260
|
+
sql += " AND user_id = ?"
|
|
261
|
+
params.append(user_id)
|
|
262
|
+
if playbook_name:
|
|
263
|
+
sql += " AND playbook_name = ?"
|
|
264
|
+
params.append(playbook_name)
|
|
265
|
+
if min_user_playbook_id is not None:
|
|
266
|
+
sql += " AND user_playbook_id > ?"
|
|
267
|
+
params.append(min_user_playbook_id)
|
|
268
|
+
if agent_version is not None:
|
|
269
|
+
sql += " AND agent_version = ?"
|
|
270
|
+
params.append(agent_version)
|
|
271
|
+
if status_filter is not None:
|
|
272
|
+
frag, sparams = _build_status_sql(status_filter)
|
|
273
|
+
sql += f" AND {frag}"
|
|
274
|
+
params.extend(sparams)
|
|
275
|
+
else:
|
|
276
|
+
# Default: exclude tombstone statuses (MERGED/SUPERSEDED)
|
|
277
|
+
sql += " AND (status IS NULL OR status NOT IN (?, ?))"
|
|
278
|
+
params.extend(_TOMBSTONE_STATUS_VALUES)
|
|
279
|
+
|
|
280
|
+
row = self._fetchone(sql, params)
|
|
281
|
+
return row["cnt"] if row else 0
|
|
282
|
+
|
|
283
|
+
@SQLiteStorageBase.handle_exceptions
|
|
284
|
+
def count_user_playbooks_by_session(self, session_id: str) -> int:
|
|
285
|
+
row = self._fetchone(
|
|
286
|
+
"""SELECT COUNT(*) as cnt FROM user_playbooks up
|
|
287
|
+
JOIN requests r ON up.request_id = r.request_id
|
|
288
|
+
WHERE r.session_id = ?
|
|
289
|
+
AND (up.status IS NULL OR up.status NOT IN (?, ?))""",
|
|
290
|
+
(session_id, *_TOMBSTONE_STATUS_VALUES),
|
|
291
|
+
)
|
|
292
|
+
return row["cnt"] if row else 0
|
|
293
|
+
|
|
294
|
+
@SQLiteStorageBase.handle_exceptions
|
|
295
|
+
def delete_all_user_playbooks(self) -> None:
|
|
296
|
+
batch_request_id = uuid.uuid4().hex
|
|
297
|
+
with self._lock:
|
|
298
|
+
ids = [
|
|
299
|
+
r["user_playbook_id"]
|
|
300
|
+
for r in self.conn.execute(
|
|
301
|
+
"SELECT user_playbook_id FROM user_playbooks"
|
|
302
|
+
).fetchall()
|
|
303
|
+
]
|
|
304
|
+
self.conn.execute("DELETE FROM user_playbooks")
|
|
305
|
+
for upid in ids:
|
|
306
|
+
_emit_hard_delete_playbook(
|
|
307
|
+
self.conn,
|
|
308
|
+
org_id=self.org_id,
|
|
309
|
+
entity_type="user_playbook",
|
|
310
|
+
entity_id=str(upid),
|
|
311
|
+
request_id=batch_request_id,
|
|
312
|
+
)
|
|
313
|
+
self.conn.commit()
|
|
314
|
+
self._delete_playbook_search_rows("user", ids)
|
|
315
|
+
|
|
316
|
+
@SQLiteStorageBase.handle_exceptions
|
|
317
|
+
def delete_user_playbook(self, user_playbook_id: int) -> None:
|
|
318
|
+
with self._lock:
|
|
319
|
+
cur = self.conn.execute(
|
|
320
|
+
"DELETE FROM user_playbooks WHERE user_playbook_id = ?",
|
|
321
|
+
(user_playbook_id,),
|
|
322
|
+
)
|
|
323
|
+
if cur.rowcount > 0:
|
|
324
|
+
_emit_hard_delete_playbook(
|
|
325
|
+
self.conn,
|
|
326
|
+
org_id=self.org_id,
|
|
327
|
+
entity_type="user_playbook",
|
|
328
|
+
entity_id=str(user_playbook_id),
|
|
329
|
+
request_id=uuid.uuid4().hex,
|
|
330
|
+
)
|
|
331
|
+
self._delete_playbook_search_rows("user", [user_playbook_id], commit=False)
|
|
332
|
+
self.conn.commit()
|
|
333
|
+
|
|
334
|
+
@SQLiteStorageBase.handle_exceptions
|
|
335
|
+
def delete_all_user_playbooks_by_playbook_name(
|
|
336
|
+
self, playbook_name: str, agent_version: str | None = None
|
|
337
|
+
) -> None:
|
|
338
|
+
sql = "SELECT user_playbook_id FROM user_playbooks WHERE playbook_name = ?"
|
|
339
|
+
params: list[Any] = [playbook_name]
|
|
340
|
+
if agent_version is not None:
|
|
341
|
+
sql += " AND agent_version = ?"
|
|
342
|
+
params.append(agent_version)
|
|
343
|
+
batch_request_id = uuid.uuid4().hex
|
|
344
|
+
with self._lock:
|
|
345
|
+
ids = [
|
|
346
|
+
r["user_playbook_id"] for r in self.conn.execute(sql, params).fetchall()
|
|
347
|
+
]
|
|
348
|
+
if not ids:
|
|
349
|
+
return
|
|
350
|
+
ph = ",".join("?" for _ in ids)
|
|
351
|
+
self.conn.execute(
|
|
352
|
+
f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})", ids
|
|
353
|
+
)
|
|
354
|
+
for upid in ids:
|
|
355
|
+
_emit_hard_delete_playbook(
|
|
356
|
+
self.conn,
|
|
357
|
+
org_id=self.org_id,
|
|
358
|
+
entity_type="user_playbook",
|
|
359
|
+
entity_id=str(upid),
|
|
360
|
+
request_id=batch_request_id,
|
|
361
|
+
)
|
|
362
|
+
self.conn.commit()
|
|
363
|
+
self._delete_playbook_search_rows("user", ids)
|
|
364
|
+
|
|
365
|
+
@SQLiteStorageBase.handle_exceptions
|
|
366
|
+
def delete_user_playbooks_by_ids(
|
|
367
|
+
self, user_playbook_ids: list[int], *, emit_hard_delete: bool = True
|
|
368
|
+
) -> int:
|
|
369
|
+
if not user_playbook_ids:
|
|
370
|
+
return 0
|
|
371
|
+
ph = ",".join("?" for _ in user_playbook_ids)
|
|
372
|
+
batch_request_id = uuid.uuid4().hex
|
|
373
|
+
with self._lock:
|
|
374
|
+
existing = [
|
|
375
|
+
r["user_playbook_id"]
|
|
376
|
+
for r in self.conn.execute(
|
|
377
|
+
f"SELECT user_playbook_id FROM user_playbooks WHERE user_playbook_id IN ({ph})",
|
|
378
|
+
user_playbook_ids,
|
|
379
|
+
).fetchall()
|
|
380
|
+
]
|
|
381
|
+
cur = self.conn.execute(
|
|
382
|
+
f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})",
|
|
383
|
+
user_playbook_ids,
|
|
384
|
+
)
|
|
385
|
+
if emit_hard_delete:
|
|
386
|
+
for upid in existing:
|
|
387
|
+
_emit_hard_delete_playbook(
|
|
388
|
+
self.conn,
|
|
389
|
+
org_id=self.org_id,
|
|
390
|
+
entity_type="user_playbook",
|
|
391
|
+
entity_id=str(upid),
|
|
392
|
+
request_id=batch_request_id,
|
|
393
|
+
actor="system",
|
|
394
|
+
)
|
|
395
|
+
self.conn.commit()
|
|
396
|
+
self._delete_playbook_search_rows("user", user_playbook_ids)
|
|
397
|
+
return cur.rowcount
|
|
398
|
+
|
|
399
|
+
@SQLiteStorageBase.handle_exceptions
|
|
400
|
+
def update_all_user_playbooks_status(
|
|
401
|
+
self,
|
|
402
|
+
old_status: Status | None,
|
|
403
|
+
new_status: Status | None,
|
|
404
|
+
agent_version: str | None = None,
|
|
405
|
+
playbook_name: str | None = None,
|
|
406
|
+
) -> int:
|
|
407
|
+
new_val = new_status.value if new_status else None
|
|
408
|
+
now_ts = _epoch_now()
|
|
409
|
+
old_val_str = old_status.value if old_status else "None"
|
|
410
|
+
new_val_str = new_status.value if new_status else "None"
|
|
411
|
+
reason = f"{old_val_str}->{new_val_str}"
|
|
412
|
+
|
|
413
|
+
if old_status is None or (
|
|
414
|
+
hasattr(old_status, "value") and old_status.value is None
|
|
415
|
+
):
|
|
416
|
+
where = "status IS NULL"
|
|
417
|
+
select_params: list[Any] = []
|
|
418
|
+
else:
|
|
419
|
+
where = "status = ?"
|
|
420
|
+
select_params = [old_status.value]
|
|
421
|
+
|
|
422
|
+
extra_params: list[Any] = []
|
|
423
|
+
if agent_version is not None:
|
|
424
|
+
where += " AND agent_version = ?"
|
|
425
|
+
extra_params.append(agent_version)
|
|
426
|
+
if playbook_name is not None:
|
|
427
|
+
where += " AND playbook_name = ?"
|
|
428
|
+
extra_params.append(playbook_name)
|
|
429
|
+
|
|
430
|
+
# Set retired_at = now when transitioning to a GC-eligible status; clear to NULL otherwise.
|
|
431
|
+
retired_at_val = now_ts if new_val in _GC_ELIGIBLE_STATUSES else None
|
|
432
|
+
|
|
433
|
+
batch_request_id = uuid.uuid4().hex
|
|
434
|
+
with self._lock:
|
|
435
|
+
affected = list(
|
|
436
|
+
self.conn.execute(
|
|
437
|
+
f"SELECT user_playbook_id, user_id, governance_subject_ref FROM user_playbooks WHERE {where}",
|
|
438
|
+
select_params + extra_params,
|
|
439
|
+
).fetchall()
|
|
440
|
+
)
|
|
441
|
+
for row in affected:
|
|
442
|
+
self._assert_subject_writable_locked(
|
|
443
|
+
self._subject_ref_from_user_playbook_row(row)
|
|
444
|
+
)
|
|
445
|
+
cur = self.conn.execute(
|
|
446
|
+
f"UPDATE user_playbooks SET status = ?, retired_at = ? WHERE {where}",
|
|
447
|
+
[new_val, retired_at_val] + select_params + extra_params,
|
|
448
|
+
)
|
|
449
|
+
from_val = old_status.value if old_status else None
|
|
450
|
+
to_val = new_status.value if new_status else None
|
|
451
|
+
for row in affected:
|
|
452
|
+
upid = row["user_playbook_id"]
|
|
453
|
+
_append_event_stmt(
|
|
454
|
+
self.conn,
|
|
455
|
+
org_id=self.org_id,
|
|
456
|
+
entity_type="user_playbook",
|
|
457
|
+
entity_id=str(upid),
|
|
458
|
+
op="status_change",
|
|
459
|
+
prov="wasInvalidatedBy",
|
|
460
|
+
source_ids=[],
|
|
461
|
+
actor="api",
|
|
462
|
+
request_id=batch_request_id,
|
|
463
|
+
reason=reason,
|
|
464
|
+
from_status=from_val,
|
|
465
|
+
to_status=to_val,
|
|
466
|
+
status_namespace="lifecycle_status",
|
|
467
|
+
)
|
|
468
|
+
self.conn.commit()
|
|
469
|
+
return cur.rowcount
|
|
470
|
+
|
|
471
|
+
@SQLiteStorageBase.handle_exceptions
|
|
472
|
+
def delete_all_user_playbooks_by_status(
|
|
473
|
+
self,
|
|
474
|
+
status: Status,
|
|
475
|
+
agent_version: str | None = None,
|
|
476
|
+
playbook_name: str | None = None,
|
|
477
|
+
) -> int:
|
|
478
|
+
# Bulk delete-by-status emits no hard_delete lineage events (parity with
|
|
479
|
+
# the Supabase backend, which routes this through _hard_delete_and_log with
|
|
480
|
+
# emit_hard_delete=False). Accepts any status: the upgrade flow legitimately
|
|
481
|
+
# deletes old ARCHIVED playbooks via _delete_items_by_status(Status.ARCHIVED).
|
|
482
|
+
where = "status = ?"
|
|
483
|
+
params: list[Any] = [status.value]
|
|
484
|
+
if agent_version is not None:
|
|
485
|
+
where += " AND agent_version = ?"
|
|
486
|
+
params.append(agent_version)
|
|
487
|
+
if playbook_name is not None:
|
|
488
|
+
where += " AND playbook_name = ?"
|
|
489
|
+
params.append(playbook_name)
|
|
490
|
+
|
|
491
|
+
with self._lock:
|
|
492
|
+
ids = [
|
|
493
|
+
r["user_playbook_id"]
|
|
494
|
+
for r in self.conn.execute(
|
|
495
|
+
f"SELECT user_playbook_id FROM user_playbooks WHERE {where}",
|
|
496
|
+
params, # noqa: S608
|
|
497
|
+
).fetchall()
|
|
498
|
+
]
|
|
499
|
+
if not ids:
|
|
500
|
+
return 0
|
|
501
|
+
ph = ",".join("?" for _ in ids)
|
|
502
|
+
cur = self.conn.execute(
|
|
503
|
+
f"DELETE FROM user_playbooks WHERE user_playbook_id IN ({ph})", # noqa: S608
|
|
504
|
+
ids,
|
|
505
|
+
)
|
|
506
|
+
self._delete_playbook_search_rows("user", ids, commit=False)
|
|
507
|
+
self.conn.commit()
|
|
508
|
+
return cur.rowcount
|
|
509
|
+
|
|
510
|
+
@SQLiteStorageBase.handle_exceptions
|
|
511
|
+
def get_user_playbooks_by_ids(
|
|
512
|
+
self,
|
|
513
|
+
user_id: str,
|
|
514
|
+
user_playbook_ids: list[int],
|
|
515
|
+
status_filter: list[Status | None] | None = None,
|
|
516
|
+
) -> list[UserPlaybook]:
|
|
517
|
+
if not user_playbook_ids:
|
|
518
|
+
return []
|
|
519
|
+
if status_filter is None:
|
|
520
|
+
status_filter = [None]
|
|
521
|
+
frag, sparams = _build_status_sql(status_filter)
|
|
522
|
+
ph = ",".join("?" for _ in user_playbook_ids)
|
|
523
|
+
sql = (
|
|
524
|
+
f"SELECT * FROM user_playbooks "
|
|
525
|
+
f"WHERE user_id = ? AND user_playbook_id IN ({ph}) AND {frag}"
|
|
526
|
+
)
|
|
527
|
+
params: list[Any] = [user_id, *user_playbook_ids, *sparams]
|
|
528
|
+
return [_row_to_user_playbook(r) for r in self._fetchall(sql, params)]
|
|
529
|
+
|
|
530
|
+
@SQLiteStorageBase.handle_exceptions
|
|
531
|
+
def archive_user_playbook_by_id(self, user_id: str, user_playbook_id: int) -> bool:
|
|
532
|
+
with self._lock:
|
|
533
|
+
row = self.conn.execute(
|
|
534
|
+
"SELECT user_id, governance_subject_ref FROM user_playbooks WHERE user_playbook_id = ? AND user_id = ?",
|
|
535
|
+
(user_playbook_id, user_id),
|
|
536
|
+
).fetchone()
|
|
537
|
+
if row is None:
|
|
538
|
+
return False
|
|
539
|
+
self._assert_subject_writable_locked(
|
|
540
|
+
self._subject_ref_from_user_playbook_row(row)
|
|
541
|
+
)
|
|
542
|
+
cur = self.conn.execute(
|
|
543
|
+
"UPDATE user_playbooks SET status = ?, retired_at = ? "
|
|
544
|
+
"WHERE user_playbook_id = ? AND user_id = ? AND status IS NULL",
|
|
545
|
+
(Status.ARCHIVED.value, _epoch_now(), user_playbook_id, user_id),
|
|
546
|
+
)
|
|
547
|
+
self.conn.commit()
|
|
548
|
+
return cur.rowcount > 0
|
|
549
|
+
|
|
550
|
+
@SQLiteStorageBase.handle_exceptions
|
|
551
|
+
def has_user_playbooks_with_status(
|
|
552
|
+
self,
|
|
553
|
+
status: Status | None,
|
|
554
|
+
agent_version: str | None = None,
|
|
555
|
+
playbook_name: str | None = None,
|
|
556
|
+
) -> bool:
|
|
557
|
+
sql = "SELECT 1 FROM user_playbooks WHERE "
|
|
558
|
+
params: list[Any] = []
|
|
559
|
+
|
|
560
|
+
if status is None or (hasattr(status, "value") and status.value is None):
|
|
561
|
+
sql += "status IS NULL"
|
|
562
|
+
else:
|
|
563
|
+
sql += "status = ?"
|
|
564
|
+
params.append(status.value)
|
|
565
|
+
|
|
566
|
+
if agent_version is not None:
|
|
567
|
+
sql += " AND agent_version = ?"
|
|
568
|
+
params.append(agent_version)
|
|
569
|
+
if playbook_name is not None:
|
|
570
|
+
sql += " AND playbook_name = ?"
|
|
571
|
+
params.append(playbook_name)
|
|
572
|
+
|
|
573
|
+
sql += " LIMIT 1"
|
|
574
|
+
row = self._fetchone(sql, params)
|
|
575
|
+
return row is not None
|
|
576
|
+
|
|
577
|
+
@SQLiteStorageBase.handle_exceptions
|
|
578
|
+
def search_user_playbooks( # noqa: C901
|
|
579
|
+
self,
|
|
580
|
+
request: SearchUserPlaybookRequest,
|
|
581
|
+
options: SearchOptions | None = None,
|
|
582
|
+
) -> list[UserPlaybook]:
|
|
583
|
+
query = request.query
|
|
584
|
+
user_id = request.user_id
|
|
585
|
+
agent_version = request.agent_version
|
|
586
|
+
playbook_name = request.playbook_name
|
|
587
|
+
start_time = int(request.start_time.timestamp()) if request.start_time else None
|
|
588
|
+
end_time = int(request.end_time.timestamp()) if request.end_time else None
|
|
589
|
+
status_filter = request.status_filter
|
|
590
|
+
match_count = request.top_k or 10
|
|
591
|
+
query_embedding = options.query_embedding if options else None
|
|
592
|
+
mode = _effective_search_mode(
|
|
593
|
+
request.search_mode, query_embedding, request.query
|
|
594
|
+
)
|
|
595
|
+
rrf_k = options.rrf_k if options else 60
|
|
596
|
+
vector_weight = options.vector_weight if options else 1.0
|
|
597
|
+
fts_weight = options.fts_weight if options else 1.0
|
|
598
|
+
|
|
599
|
+
conditions: list[str] = []
|
|
600
|
+
params: list[Any] = []
|
|
601
|
+
|
|
602
|
+
if user_id:
|
|
603
|
+
conditions.append("up.user_id = ?")
|
|
604
|
+
params.append(user_id)
|
|
605
|
+
if agent_version:
|
|
606
|
+
conditions.append("up.agent_version = ?")
|
|
607
|
+
params.append(agent_version)
|
|
608
|
+
if playbook_name:
|
|
609
|
+
conditions.append("up.playbook_name = ?")
|
|
610
|
+
params.append(playbook_name)
|
|
611
|
+
if start_time:
|
|
612
|
+
conditions.append("up.created_at >= ?")
|
|
613
|
+
params.append(_epoch_to_iso(start_time))
|
|
614
|
+
if end_time:
|
|
615
|
+
conditions.append("up.created_at <= ?")
|
|
616
|
+
params.append(_epoch_to_iso(end_time))
|
|
617
|
+
if status_filter is not None:
|
|
618
|
+
frag, sparams = _build_status_sql(status_filter)
|
|
619
|
+
conditions.append(frag)
|
|
620
|
+
params.extend(sparams)
|
|
621
|
+
else:
|
|
622
|
+
# Default: exclude tombstone statuses (MERGED/SUPERSEDED)
|
|
623
|
+
conditions.append("(up.status IS NULL OR up.status NOT IN (?, ?))")
|
|
624
|
+
params.extend(_TOMBSTONE_STATUS_VALUES)
|
|
625
|
+
tag_frag, tag_params = _build_tags_sql("up", request.tags)
|
|
626
|
+
if tag_frag:
|
|
627
|
+
conditions.append(tag_frag)
|
|
628
|
+
params.extend(tag_params)
|
|
629
|
+
|
|
630
|
+
where_extra = (" AND " + " AND ".join(conditions)) if conditions else ""
|
|
631
|
+
overfetch = match_count * 5 if mode != SearchMode.FTS else match_count
|
|
632
|
+
|
|
633
|
+
# Pure vector search: fetch all candidates, rank by cosine similarity
|
|
634
|
+
if mode == SearchMode.VECTOR and query_embedding:
|
|
635
|
+
base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
636
|
+
sql = f"""SELECT * FROM user_playbooks up
|
|
637
|
+
{base_where}
|
|
638
|
+
ORDER BY up.created_at DESC"""
|
|
639
|
+
rows = self._fetchall(sql, params)
|
|
640
|
+
rows = _vector_rank_rows(rows, query_embedding, match_count)
|
|
641
|
+
return [_row_to_user_playbook(r) for r in rows]
|
|
642
|
+
|
|
643
|
+
if query:
|
|
644
|
+
fts_query = _sanitize_fts_query(query)
|
|
645
|
+
sql = f"""SELECT up.* FROM user_playbooks up
|
|
646
|
+
JOIN user_playbooks_fts f ON up.user_playbook_id = f.rowid
|
|
647
|
+
WHERE user_playbooks_fts MATCH ?{where_extra}
|
|
648
|
+
ORDER BY bm25(user_playbooks_fts, 1.0)
|
|
649
|
+
LIMIT ?"""
|
|
650
|
+
fts_rows = self._fetchall(sql, [fts_query, *params, overfetch])
|
|
651
|
+
|
|
652
|
+
if mode == SearchMode.HYBRID and query_embedding:
|
|
653
|
+
base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
654
|
+
vec_limit = match_count * 10
|
|
655
|
+
vec_sql = f"""SELECT * FROM user_playbooks up
|
|
656
|
+
{base_where}
|
|
657
|
+
ORDER BY up.created_at DESC
|
|
658
|
+
LIMIT ?"""
|
|
659
|
+
vec_candidates = self._fetchall(vec_sql, [*params, vec_limit])
|
|
660
|
+
vec_rows = _vector_rank_rows(vec_candidates, query_embedding, overfetch)
|
|
661
|
+
rows = _true_rrf_merge(
|
|
662
|
+
fts_rows,
|
|
663
|
+
vec_rows,
|
|
664
|
+
"user_playbook_id",
|
|
665
|
+
match_count,
|
|
666
|
+
rrf_k,
|
|
667
|
+
vector_weight,
|
|
668
|
+
fts_weight,
|
|
669
|
+
)
|
|
670
|
+
return [_row_to_user_playbook(r) for r in rows]
|
|
671
|
+
return [_row_to_user_playbook(r) for r in fts_rows[:match_count]]
|
|
672
|
+
|
|
673
|
+
# HYBRID without query text: rank by embedding only
|
|
674
|
+
if query_embedding:
|
|
675
|
+
base_where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
676
|
+
sql = f"""SELECT * FROM user_playbooks up
|
|
677
|
+
{base_where}
|
|
678
|
+
ORDER BY up.created_at DESC"""
|
|
679
|
+
rows = self._fetchall(sql, params)
|
|
680
|
+
rows = _vector_rank_rows(rows, query_embedding, match_count)
|
|
681
|
+
return [_row_to_user_playbook(r) for r in rows]
|
|
682
|
+
|
|
683
|
+
# No query text, no embedding -- recency fallback
|
|
684
|
+
base_where = "WHERE " + " AND ".join(conditions) if conditions else "WHERE 1=1"
|
|
685
|
+
sql = f"""SELECT * FROM user_playbooks up
|
|
686
|
+
{base_where}
|
|
687
|
+
ORDER BY up.created_at DESC LIMIT ?"""
|
|
688
|
+
params.append(match_count)
|
|
689
|
+
rows = self._fetchall(sql, params)
|
|
690
|
+
return [_row_to_user_playbook(r) for r in rows]
|
|
691
|
+
|
|
692
|
+
@SQLiteStorageBase.handle_exceptions
|
|
693
|
+
def get_user_playbook_by_id(
|
|
694
|
+
self, user_playbook_id: int, *, include_tombstones: bool = False
|
|
695
|
+
) -> UserPlaybook | None:
|
|
696
|
+
sql = "SELECT * FROM user_playbooks WHERE user_playbook_id = ?"
|
|
697
|
+
if not include_tombstones:
|
|
698
|
+
sql += " AND (status IS NULL OR status NOT IN (?, ?))"
|
|
699
|
+
row = self._fetchone(sql, (user_playbook_id, *_TOMBSTONE_STATUS_VALUES))
|
|
700
|
+
else:
|
|
701
|
+
row = self._fetchone(sql, (user_playbook_id,))
|
|
702
|
+
return _row_to_user_playbook(row) if row else None
|
|
703
|
+
|
|
704
|
+
@SQLiteStorageBase.handle_exceptions
|
|
705
|
+
def get_user_playbooks_by_ids_any_user(
|
|
706
|
+
self,
|
|
707
|
+
user_playbook_ids: list[int],
|
|
708
|
+
status_filter: list[Status | None] | None = None,
|
|
709
|
+
) -> list[UserPlaybook]:
|
|
710
|
+
if not user_playbook_ids:
|
|
711
|
+
return []
|
|
712
|
+
ph = ",".join("?" for _ in user_playbook_ids)
|
|
713
|
+
sql = f"SELECT * FROM user_playbooks WHERE user_playbook_id IN ({ph})" # noqa: S608
|
|
714
|
+
params: list[Any] = list(user_playbook_ids)
|
|
715
|
+
if status_filter is not None:
|
|
716
|
+
frag, sparams = _build_status_sql(status_filter)
|
|
717
|
+
sql += f" AND {frag}"
|
|
718
|
+
params.extend(sparams)
|
|
719
|
+
rows = self._fetchall(sql, params)
|
|
720
|
+
by_id = {
|
|
721
|
+
_row_to_user_playbook(row).user_playbook_id: _row_to_user_playbook(row)
|
|
722
|
+
for row in rows
|
|
723
|
+
}
|
|
724
|
+
return [by_id[upid] for upid in user_playbook_ids if upid in by_id]
|
|
725
|
+
|
|
726
|
+
@SQLiteStorageBase.handle_exceptions
|
|
727
|
+
def update_user_playbook(
|
|
728
|
+
self,
|
|
729
|
+
user_playbook_id: int,
|
|
730
|
+
playbook_name: str | None = None,
|
|
731
|
+
content: str | None = None,
|
|
732
|
+
trigger: str | None = None,
|
|
733
|
+
rationale: str | None = None,
|
|
734
|
+
blocking_issue: BlockingIssue | None = None,
|
|
735
|
+
tags: list[str] | None = None,
|
|
736
|
+
) -> None:
|
|
737
|
+
updates: list[str] = []
|
|
738
|
+
params: list[Any] = []
|
|
739
|
+
if playbook_name is not None:
|
|
740
|
+
updates.append("playbook_name = ?")
|
|
741
|
+
params.append(playbook_name)
|
|
742
|
+
if content is not None:
|
|
743
|
+
updates.append("content = ?")
|
|
744
|
+
params.append(content)
|
|
745
|
+
if trigger is not None:
|
|
746
|
+
updates.append("trigger = ?")
|
|
747
|
+
params.append(trigger)
|
|
748
|
+
if rationale is not None:
|
|
749
|
+
updates.append("rationale = ?")
|
|
750
|
+
params.append(rationale)
|
|
751
|
+
if blocking_issue is not None:
|
|
752
|
+
updates.append("blocking_issue = ?")
|
|
753
|
+
params.append(json.dumps(blocking_issue.model_dump()))
|
|
754
|
+
if tags is not None:
|
|
755
|
+
updates.append("tags = ?")
|
|
756
|
+
params.append(_json_dumps(tags))
|
|
757
|
+
if updates:
|
|
758
|
+
params.append(user_playbook_id)
|
|
759
|
+
semantic_change = any(
|
|
760
|
+
value is not None for value in (content, trigger, rationale)
|
|
761
|
+
)
|
|
762
|
+
op = "revise" if semantic_change else "status_change"
|
|
763
|
+
prov = "wasRevisionOf" if op == "revise" else "wasInvalidatedBy"
|
|
764
|
+
with self._lock:
|
|
765
|
+
if self._assert_user_playbook_writable_locked(user_playbook_id) is None:
|
|
766
|
+
return
|
|
767
|
+
cur = self.conn.execute(
|
|
768
|
+
f"UPDATE user_playbooks SET {', '.join(updates)} WHERE user_playbook_id = ?",
|
|
769
|
+
tuple(params),
|
|
770
|
+
)
|
|
771
|
+
if cur.rowcount > 0:
|
|
772
|
+
_append_event_stmt(
|
|
773
|
+
self.conn,
|
|
774
|
+
org_id=self.org_id,
|
|
775
|
+
entity_type="user_playbook",
|
|
776
|
+
entity_id=str(user_playbook_id),
|
|
777
|
+
op=op,
|
|
778
|
+
prov=prov,
|
|
779
|
+
source_ids=[],
|
|
780
|
+
actor="api",
|
|
781
|
+
request_id=uuid.uuid4().hex,
|
|
782
|
+
reason="in-place update",
|
|
783
|
+
from_status=None,
|
|
784
|
+
to_status=None,
|
|
785
|
+
status_namespace=None,
|
|
786
|
+
)
|
|
787
|
+
self.conn.commit()
|
|
788
|
+
|
|
789
|
+
@SQLiteStorageBase.handle_exceptions
|
|
790
|
+
def supersede_user_playbooks_by_ids(
|
|
791
|
+
self, user_playbook_ids: list[int], request_id: str
|
|
792
|
+
) -> int:
|
|
793
|
+
"""Soft-delete user playbooks by setting status to SUPERSEDED.
|
|
794
|
+
|
|
795
|
+
Preserves the row content for strict point-in-time attribution reads.
|
|
796
|
+
Eligible rows are any non-tombstoned status (CURRENT / PENDING /
|
|
797
|
+
ARCHIVED). Atomic: all updates and lineage events commit together.
|
|
798
|
+
"""
|
|
799
|
+
if not user_playbook_ids:
|
|
800
|
+
return 0
|
|
801
|
+
if not request_id:
|
|
802
|
+
raise ValueError("request_id must be non-empty for supersede")
|
|
803
|
+
now_ts = _epoch_now()
|
|
804
|
+
updated = 0
|
|
805
|
+
with self._lock:
|
|
806
|
+
for upid in user_playbook_ids:
|
|
807
|
+
row = self.conn.execute(
|
|
808
|
+
"SELECT status, user_id, governance_subject_ref FROM user_playbooks WHERE user_playbook_id = ?",
|
|
809
|
+
(upid,),
|
|
810
|
+
).fetchone()
|
|
811
|
+
if row is None:
|
|
812
|
+
continue
|
|
813
|
+
self._assert_subject_writable_locked(
|
|
814
|
+
self._subject_ref_from_user_playbook_row(row)
|
|
815
|
+
)
|
|
816
|
+
old_status = row["status"]
|
|
817
|
+
cur = self.conn.execute(
|
|
818
|
+
"UPDATE user_playbooks SET status = ?, retired_at = ?"
|
|
819
|
+
" WHERE user_playbook_id = ?"
|
|
820
|
+
" AND (status IS NULL OR status NOT IN (?, ?))",
|
|
821
|
+
(
|
|
822
|
+
Status.SUPERSEDED.value,
|
|
823
|
+
now_ts,
|
|
824
|
+
upid,
|
|
825
|
+
*_TOMBSTONE_STATUS_VALUES,
|
|
826
|
+
),
|
|
827
|
+
)
|
|
828
|
+
if cur.rowcount > 0:
|
|
829
|
+
_emit_supersede_user_playbook(
|
|
830
|
+
self.conn,
|
|
831
|
+
org_id=self.org_id,
|
|
832
|
+
entity_id=str(upid),
|
|
833
|
+
old_status=old_status,
|
|
834
|
+
request_id=request_id,
|
|
835
|
+
)
|
|
836
|
+
updated += 1
|
|
837
|
+
self.conn.commit()
|
|
838
|
+
return updated
|